Progressive CSS rendering with or without data URLs
September 26, 2020
0 comments Web development, Web Performance, JavaScript
You can write your CSS so that it depends on images. Like this:
li.one {
background-image: url("skull.png");
}
That means that the browser will do its best to style the li.one
with what little it has from the CSS. Then, it'll ask the browser to go ahead and network download that skull.png
URL.
But, another option is to embed the image as a data URL like this:
li.one{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHL...rkJggg==)
As a block of CSS, it's much larger but it's one less network call. What if you know that skull.png
will be needed? Is it faster to inline it or to leave it as a URL? Let's see!
First of all, I wanted to get a feeling for how much larger an image is in bytes if you transform them to data URLs. Check out this script's output:
▶ ./bin/b64datauri.js src/*.png src/*.svg src/lizard.png 43,551 58,090 1.3x src/skull.png 7,870 10,518 1.3x src/clippy.svg 483 670 1.4x src/curve.svg 387 542 1.4x src/dino.svg 909 1,238 1.4x src/sprite.svg 10,330 13,802 1.3x src/survey.svg 2,069 2,786 1.3x
Basically, as a blob of data URL, the images become about 1.3x larger. Hopefully, with HTTP2, the headers are cheap for each URL downloaded over the network, but it's not 0. (No idea what the CPU-work multiplier is)
Experiment assumptions and notes
- When you first calculate the critical CSS, you know that there's no
url(data:mime/type;base64,....)
that goes to waste. I.e. you didn't put that in the CSS file or HTML file, bloating it, for nothing. - The images aren't too large. Mostly icons and fluff.
- If it's SVG images should probably inline them in the HTML natively so you can control their style.
- The HTML is compressed for best results.
- The server is HTTP2
It's a fairly commonly known fact that data URLs have a CPU cost. That base64 needs to be decoded before the image can be decoded by the renderer. So let's stick to fairly small images.
The experiment
I made a page that looks like this:
li {
background-repeat: no-repeat;
width: 150px;
height: 150px;
margin: 20px;
background-size: contain;
}
li.one {
background-image: url("skull.png");
}
li.two {
background-image: url("dino.svg");
}
li.three {
background-image: url("clippy.svg");
}
li.four {
background-image: url("sprite.svg");
}
li.five {
background-image: url("survey.svg");
}
li.six {
background-image: url("curve.svg");
}
and
<ol>
<li class="one">One</li>
<li class="two">Two</li>
<li class="three">Three</li>
<li class="four">Four</li>
<li class="five">Five</li>
<li class="six">Six</li>
</ol>
The page also uses Bootstrap to make it somewhat realistic. Then, using minimalcss
combine the external CSS with the CSS inline and produce a page that is just HTML + 1 <style>
tag.
Now, based on that page, the variant is that each url($URL)
in the CSS gets converted to url(data:mime/type;base64,blablabla...)
. The HTML is gzipped (and brotli compressed) and put behind a CDN. The URLs are:
- https://www.peterbe.com/render-block-images-in-css/inlined.html - All CSS inlined but using image URLs.
- https://www.peterbe.com/render-block-images-in-css/inlined-datauri.html - All CSS inlined and all image URLs turn into data URLs.
Also, there's this page which is without the critical CSS inlined.
To appreciate what this means in terms of size on the HTML, let's compare:
inlined.html
with external URLs: 2,801 bytes (1,282 gzipped)inlined-datauris.html
with data URLs: 32,289 bytes (17,177 gzipped)
Considering that gzip (accept-encoding: gzip,deflate
) is almost always used by browsers, that means the page is 15KB more before it can be fully downloaded. (But, it's streamed so maybe the comparison is a bit flawed)
Analysis
WebPagetest.org results here. I love WebPagetest, but the results are usually a bit erratic to be a good enough for comparing. Maybe if you could do the visual comparison repeated times, but I don't think you can.
And the waterfalls...
Fairly expected.
-
With external image URLs, the browser will start to display the CSSOM before the images have downloaded. Meaning, the CSS is render-blocking, but the external images are not.
-
The final result comes in sooner with data URLs.
-
With data URLs you have to stare at a white screen longer.
Next up, using Google Chrome's Performance dev tools panel. Set to 6x CPU slowdown and online with Fast 3G.
I don't know how to demonstrate this other than screenshots:
Performance with external images
Those screenshots are rough attempts at showing the area when it starts to display the images.
Whole Performance tab with external images
Whole Performance tab with data URLs
I ran these things 2 times and the results were pretty steady.
- With external images, fully loaded at about 2.5 seconds
- With data URLs, fully loaded at 1.9 seconds
I tried Lighthouse but the difference was indistinguishable.
Summary
Yes, inlining your CSS images is faster. But it's with a slim margin and the disadvantages aren't negligible.
This technique costs more CPU because there's a lot more base64 decoding to be done, and what if you have a big fat JavaScript bundle in there that wants a piece of the CPU? So ask yourself, how valuable is to not hog the CPU. Perhaps someone who understands the browser engines better can tell if the base64 decoding cost is spread nicely onto multiple CPUs or if it would stand in the way of the main thread.
What about anti-progressive rendering
When Facebook redesigned www.facebook.com in mid-2020 one of their conscious decisions was to inline the SVG glyphs into the JavaScript itself.
"To prevent flickering as icons come in after the rest of the content, we inline SVGs into the HTML using React rather than passing SVG files to <img>
tags."
Although that comment was about SVGs in the DOM, from a JavaScript perspective, the point is nevertheless relevant to my experiment. If you look closely, at the screenshots above (or you open the URL yourself and hit reload with HTTP caching disabled) the net effect is that the late-loading images do cause a bit of "flicker". It's not flickering as in "now it's here", "now it's gone", "now it's back again". But it's flickering in that things are happening with progressive rendering. Your eyes might get tired and they say to your brain "Wake me up when the whole thing is finished. I can wait."
This topic quickly escalates into perceived performance which is a stratosphere of its own. And personally, I can only estimate and try to speak about my gut reactions.
In conclusion, there are advantages to using data URIs over external images in CSS. But please, first make sure you don't convert the image URLs in a big bloated .css
file to data URLs if you're not sure they'll all be needed in the DOM.
Bonus!
If you're not convinced of the power of inlining the critical CSS, check out this WebPagetest run that includes the image where it references the whole bootstrap.min.css
as before doing any other optimizations.
Lazy-load Firebase Firestore and Firebase Authentication in Preact
September 2, 2020
0 comments Web development, Web Performance, JavaScript, Preact
I'm working on a Firebase app called That's Groce! based on preact-cli
, with TypeScript, and I wanted to see how it appears with or without Firestore and Authenticated lazy-loaded.
In the root, there's an app.tsx
that used look like this:
import { FunctionalComponent, h } from "preact";
import { useState, useEffect } from "preact/hooks";
import firebase from "firebase/app";
import "firebase/auth";
import "firebase/firestore";
import { firebaseConfig } from "./firebaseconfig";
const app = firebase.initializeApp(firebaseConfig);
const App: FunctionalComponent = () => {
const [auth, setAuth] = useState<firebase.auth.Auth | null>(null);
const [db, setDB] = useState<firebase.firestore.Firestore | null>(null);
useEffect(() => {
const appAuth = app.auth();
setAuth(appAuth);
appAuth.onAuthStateChanged(authStateChanged);
const db = firebase.firestore();
setDB(db);
}, []);
...
While this works, it does make a really large bundle when both firebase/firestore
and firebase/auth
imported in the main bundle. In fact, it looks like this:
▶ ls -lh build/*.esm.js -rw-r--r-- 1 peterbe staff 510K Sep 1 14:13 build/bundle.0438b.esm.js -rw-r--r-- 1 peterbe staff 5.0K Sep 1 14:13 build/polyfills.532e0.esm.js
510K is pretty hefty to have to ask the client to download immediately. It's loaded like this (in build/index.html
):
<script crossorigin="anonymous" src="/bundle.0438b.esm.js" type="module"></script>
<script nomodule src="/polyfills.694cb.js"></script>
<script nomodule defer="defer" src="/bundle.a4a8b.js"></script>
To lazy-load this
To lazy-load the firebase/firestore
and firebase/auth
you do this instead:
...
const App: FunctionalComponent = () => {
const [auth, setAuth] = useState<firebase.auth.Auth | null>(null);
const [db, setDB] = useState<firebase.firestore.Firestore | null>(null);
useEffect(() => {
import("firebase/auth")
.then(() => {
const appAuth = app.auth();
setAuth(appAuth);
appAuth.onAuthStateChanged(authStateChanged);
})
.catch((error) => {
console.error("Unable to lazy-load firebase/auth:", error);
});
import("firebase/firestore")
.then(() => {
const db = firebase.firestore();
setDB(db);
})
.catch((error) => {
console.error("Unable to lazy-load firebase/firestore:", error);
});
}, []);
...
Now it looks like this instead:
▶ ls -lh build/*.esm.js -rw-r--r-- 1 peterbe staff 173K Sep 1 14:24 build/11.chunk.b8684.esm.js -rw-r--r-- 1 peterbe staff 282K Sep 1 14:24 build/12.chunk.3c1c4.esm.js -rw-r--r-- 1 peterbe staff 56K Sep 1 14:24 build/bundle.7225c.esm.js -rw-r--r-- 1 peterbe staff 5.0K Sep 1 14:24 build/polyfills.532e0.esm.js
The total sum of all (relevant) .esm.js
files is the same (minus a difference of 430 bytes).
But what does it really look like? The app is already based around that
const [db, setDB] = useState<firebase.firestore.Firestore | null>(null);
so it knows to wait until db
is truthy and it displays a <Loading/>
component until it's ready.
To test how it loads I used the Chrome Performance devtools with or without the lazy-loading and it's fairly self-explanatory:
Clearly, the lazy-loaded has a nicer pattern in that it breaks up the work by the main thread.
Conclusion
It's fairly simple to do and it works. The main bundle becomes lighter and allows the browser to start rendering the Preact component sooner. But it's not entirely obvious if it's that much better. The same amount of JavaScript needs to downloaded and parsed no matter what. It's clearly working as a pattern but it's still pretty hard to judge if it's worth it. Now there's more "swapping".
And the whole page is server-side rendered anyway so in terms of immediate first-render it's probably the same. Hopefully, HTTP2 loading does the right thing but it's not yet entirely clear if the complete benefit is there. I certainly hope that this can improve the "Total Blocking Time" and "Time to Interactive".
The other important thing is that not all imports from firebase/*
work in Node because they depend on window
. It works for firebase/firestore
and firestore/auth
but not for firestore/analytics
and firestore/performance
. Now, I can add those lazy-loaded in the client and have the page rendered in Node for that initial build/index.html
.
<datalist> looks great on mobile devices
August 28, 2020
0 comments Web development, Mobile
<datalist>
is an underrated HTML API. It's basically a native autocomplete widget that requires 0 JavaScript.
What I didn't know is how great it is on mobile devices. Especially the iOS Safari platform which is, to be honest, the only mobile device I have.
What's cool about it is that it's easy to implement, from a developer's point-of-view. But most importantly, it works great for users. The problem usually on mobile devices and autocomplete is that it's hard to find a good spot to display the suggestions. Most autocomplete widgets are a styled form of <div class="results"><ul><li>Suggestion 1</li><li>Suggestion 2</li></ul></div>
that usually follows the <input>
element. Usually, this simply boils down to screen height real estate. Oftentimes you want to display so much more rich stuff in the autocomplete results but it's hard to fit in a nice list of results between the <input>
and the native keyboard display. For example, on this page ...
Note how the search results get hidden underneath the keyboard.
Demo
The cool thing about <datalist>
is that gets embedded in the native mobile keyboard in a sense. But what's extra cool is that the browser will do an OK job of filtering all the options for you, so that you, as a developer, just need to supply all options and the browser will take care of the rest.
I put together a dead-simple app here: https://cnfyl.csb.app/ (source here) which looks like this on iOS:
Caveats
The space that the keyboard now populates with suggestions is usually reserved for helping you autocomplete regular words. It still does if you start typing a word that isn't an option. So arguably, the <datalist>
options are primarily helping you when it's very likely that the user will type one of the suggestions.
The matching isn't great in my opinion. If you type "ea" it will match "Peaches" but I find it extremely unlikely that that's helping users. (What do you think?) If you've started typing "ea" if there's no match called "Each" or "North East" then it's probably better with no match at all.
Mind you, check out this hack (source here) which takes control of the <option>
tags inside the <datalist>
by having an event listener on the input. So if the input is "ea" it only matches expressions that are left-word-delimited and discard the rest.
Conclusion
It is without a doubt the simplest autocomplete functionality you can buy. I would buy it again.
Perhaps it's not right for every application. Perhaps it's important to be able to include images in your autocomplete suggestions. Either way, the best thing to do is to park this in the back of your mind till next time you're up against the need for some sort of assisted search or choice. Especially if you predict you'll have a lot of users on mobile devices.
How to use minimalcss without a server
April 24, 2020
0 comments Web development, Node, JavaScript
minimalcss requires that you have your HTML in a serving HTTP web page so that puppeteer
can open it to find out the CSS within. Suppose, in your build system, you don't yet really have a server. Well, what you can do is start one on-the-fly and shut it down as soon as you're done.
Suppose you have .html file
First install all the stuff:
yarn add minimalcss http-server
Then run it:
const path = require("path");
const minimalcss = require("minimalcss");
const httpServer = require("http-server");
const HTML_FILE = "index.html"; // THIS IS YOURS
(async () => {
const server = httpServer.createServer({
root: path.dirname(path.resolve(HTML_FILE)),
});
server.listen(8080);
let result;
try {
result = await minimalcss.minimize({
urls: ["http://0.0.0.0:8080/" + HTML_FILE],
});
} catch (err) {
console.error(err);
throw err;
} finally {
server.close();
}
console.log(result.finalCss);
})();
And the index.html
file:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<p>Hi @peterbe</p>
</body>
</html>
And the styles.css
file:
h1 {
color: red;
}
p,
li {
font-weight: bold;
}
And the output from running that Node script:
p{font-weight:700}
It works!
Suppose all you have is the HTML string and the CSS blob(s)
Suppose all you have is a string of HTML and a list of strings of CSS:
const fs = require("fs");
const path = require("path");
const minimalcss = require("minimalcss");
const httpServer = require("http-server");
const HTML_BODY = `
<p>Hi Peterbe</p>
`;
const CSSes = [
`
h1 {
color: red;
}
p,
li {
font-weight: bold;
}
`,
];
(async () => {
const csses = CSSes.map((css, i) => {
fs.writeFileSync(`${i}.css`, css);
return `<link rel="stylesheet" href="${i}.css">`;
});
const html = `<!doctype html><html>
<head>${csses}</head>
<body>${HTML_BODY}</body>
</html>`;
const fp = path.resolve("./index.html");
fs.writeFileSync(fp, html);
const server = httpServer.createServer({
root: path.dirname(fp),
});
server.listen(8080);
let result;
try {
result = await minimalcss.minimize({
urls: ["http://0.0.0.0:8080/" + path.basename(fp)],
});
} catch (err) {
console.error(err);
throw err;
} finally {
server.close();
fs.unlinkSync(fp);
CSSes.forEach((_, i) => fs.unlinkSync(`${i}.css`));
}
console.log(result.finalCss);
})();
Truth be told, you'll need a good pinch of salt to appreciate that example code. It works but most likely, if you're into web performance so much that you're even doing this, your parameters are likely to be more complex.
Suppose you have your own puppeteer
instance
In the first example above, minimalcss
will create an instance of puppeteer
(e.g. const browser = await puppeteer.launch()
) but that means you have less control over which version of puppeteer
or which parameters you need. Also, if you have to run minimalcss
on a bunch of pages it's costly to have to create and destroy puppeteer
browser instances repeatedly.
To modify the original example, here's how you use your own instance of puppeteer
:
const path = require("path");
+ const puppeteer = require("puppeteer");
const minimalcss = require("minimalcss");
const httpServer = require("http-server");
const HTML_FILE = "index.html"; // THIS IS YOURS
(async () => {
const server = httpServer.createServer({
root: path.dirname(path.resolve(HTML_FILE)),
});
server.listen(8080);
+ const browser = await puppeteer.launch(/* your special options */);
+
let result;
try {
result = await minimalcss.minimize({
urls: ["http://0.0.0.0:8080/" + HTML_FILE],
+ browser,
});
} catch (err) {
console.error(err);
throw err;
} finally {
+ await browser.close();
server.close();
}
console.log(result.finalCss);
})();
Note that this doesn't buy us anything in this particular example. But that's where your imagination comes in!
Conclusion
You can see the code here as a git
repo if that helps.
The point is that this might solve some of the chicken-and-egg problem you might have is that you're building your perfect HTML + CSS and you want to perfect it before you ship it.
Note also that there are other ways to run minimalcss
other than programmatically. For example, minimalcss-server
is minimalcss
wrapped in a express
server.
Another thing that you might have is that you have multiple .html
files that you want to process. The same technique applies but you just need to turn it into a loop and make sure you call server.close()
(and optionally await browser.close()
) when you know you've processed the last file. Exercise left to the reader?
How to have default/initial values in a Django form that is bound and rendered
January 10, 2020
10 comments Web development, Django, Python
Django's Form framework is excellent. It's intuitive and versatile and, best of all, easy to use. However, one little thing that is not so intuitive is how do you render a bound form with default/initial values when the form is never rendered unbound.
If you do this in Django:
class MyForm(forms.Form):
name = forms.CharField(required=False)
def view(request):
form = MyForm(initial={'name': 'Peter'})
return render(request, 'page.html', form=form)
# Imagine, in 'page.html' that it does this:
# <label>Name:</label>
# {{ form.name }}
...it will render out this:
<label>Name:</label>
<input type="text" name="name" value="Peter">
The whole initial
trick is something you can set on the whole form or individual fields. But it's only used in UN-bound forms when rendered.
If you change your view function to this:
def view(request):
form = MyForm(request.GET, initial={'name': 'Peter'}) # data passed!
if form.is_valid(): # makes it bound!
print(form.cleaned_data['name'])
return render(request, 'page.html', form=form)
Now, the form
is bound and the initial
stuff is essentially ignored.
Because name
is not present in request.GET
. And if it was present, but an empty string, it wouldn't be able to benefit for the default value.
My solution
I tried many suggestions and tricks (based on rapid Stackoverflow searching) and nothing worked.
I knew one thing: Only the view should know the actual initial values.
Here's what works:
import copy
class MyForm(forms.Form):
name = forms.CharField(required=False)
def __init__(self, data, **kwargs):
initial = kwargs.get('initial', {})
data = {**initial, **data}
super().__init__(data, **kwargs)
Now, suppose you don't have ?name=something
in request.GET
the line print(form.cleaned_data['name'])
will print Peter
and the rendered form will look like this:
<label>Name:</label>
<input type="text" name="name" value="Peter">
And, as expected, if you have ?name=Ashley
in request.GET
it will print Ashley
and produce this rendered HTML too:
<label>Name:</label>
<input type="text" name="name" value="Ashley">
UPDATE June 2020
If data
is a QueryDict
object (e.g. <QueryDict: {'days': ['90']}>
), and initial
is a plain dict (e.g. {'days': 30}
),
then you can merge these with {**data, **initial}
because it produces a plain dict of value {'days': [90]}
which Django's form stuff doesn't know is supposed to be "flattened".
The solution is to use:
from django.utils.datastructures import MultiValueDict
...
def __init__(self, data, **kwargs):
initial = kwargs.get("initial", {})
data = MultiValueDict({**{k: [v] for k, v in initial.items()}, **data})
super().__init__(data, **kwargs)
(To be honest; this might work in the app I'm currently working on but I don't feel confident that this is covering all cases)
A Python and Preact app deployed on Heroku
December 13, 2019
2 comments Web development, Django, Python, Docker, JavaScript
Heroku is great but it's sometimes painful when your app isn't just in one single language. What I have is a project where the backend is Python (Django) and the frontend is JavaScript (Preact). The folder structure looks like this:
/ - README.md - manage.py - requirements.txt - my_django_app/ - settings.py - asgi.py - api/ - urls.py - views.py - frontend/ - package.json - yarn.lock - preact.config.js - build/ ... - src/ ...
A bunch of things omitted for brevity but people familiar with Django and preact-cli/create-create-app should be familiar.
The point is that the root is a Python app and the front-end is exclusively inside a sub folder.
When you do local development, you start two servers:
./manage.py runserver
- startshttp://localhost:8000
cd frontend && yarn start
- startshttp://localhost:3000
The latter is what you open in your browser. That preact
app will do things like:
const response = await fetch('/api/search');
and, in preact.config.js
I have this:
export default (config, env, helpers) => {
if (config.devServer) {
config.devServer.proxy = [
{
path: "/api/**",
target: "http://localhost:8000"
}
];
}
};
...which is hopefully self-explanatory. So, calls like GET http://localhost:3000/api/search
actually goes to http://localhost:8000/api/search
.
That's when doing development. The interesting thing is going into production.
Before we get into Heroku, let's first "merge" the two systems into one and the trick used is Whitenoise. Basically, Django's web server will be responsibly not only for things like /api/search
but also static assets such as / --> frontend/build/index.html
and /bundle.17ae4.js --> frontend/build/bundle.17ae4.js
.
This is basically all you need in settings.py
to make that happen:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
...
]
WHITENOISE_INDEX_FILE = True
STATIC_URL = "/"
STATIC_ROOT = BASE_DIR / "frontend" / "build"
However, this isn't quite enough because the preact
app uses preact-router
which uses pushState()
and other code-splitting magic so you might have a URL, that users see, like this: https://myapp.example.com/that/thing/special
and there's nothing about that in any of the Django urls.py
files. Nor is there any file called frontend/build/that/thing/special/index.html
or something like that.
So for URLs like that, we have to take a gamble on the Django side and basically hope that the preact-router
config knows how to deal with it. So, to make that happen with Whitenoise we need to write a custom middleware that looks like this:
from whitenoise.middleware import WhiteNoiseMiddleware
class CustomWhiteNoiseMiddleware(WhiteNoiseMiddleware):
def process_request(self, request):
if self.autorefresh:
static_file = self.find_file(request.path_info)
else:
static_file = self.files.get(request.path_info)
# These two lines is the magic.
# Basically, the URL didn't lead to a file (e.g. `/manifest.json`)
# it's either a API path or it's a custom browser path that only
# makes sense within preact-router. If that's the case, we just don't
# know but we'll give the client-side preact-router code the benefit
# of the doubt and let it through.
if not static_file and not request.path_info.startswith("/api"):
static_file = self.files.get("/")
if static_file is not None:
return self.serve(static_file, request)
And in settings.py
this change:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
- "whitenoise.middleware.WhiteNoiseMiddleware",
+ "my_django_app.middleware.CustomWhiteNoiseMiddleware",
...
]
Now, all traffic goes through Django. Regular Django view functions, static assets, and everything else fall back to frontend/build/index.html
.
Heroku
Heroku tries to make everything so simple for you. You basically, create the app (via the cli or the Heroku web app) and when you're ready you just do git push heroku master
. However that won't be enough because there's more to this than Python.
Unfortunately, I didn't take notes of my hair-pulling excruciating journey of trying to add buildpacks and hacks and Procfile
s and custom buildpacks. Nothing seemed to work. Perhaps the answer was somewhere in this issue: "Support running an app from a subdirectory" but I just couldn't figure it out. I still find buildpacks confusing when it's beyond Hello World. Also, I didn't want to run Node as a service, I just wanted it as part of the "build process".
Docker to the rescue
Finally I get a chance to try "Deploying with Docker" in Heroku which is a relatively new feature. And the only thing that scared me was that now I need to write a heroku.yml
file which was confusing because all I had was a Dockerfile
. We'll get back to that in a minute!
So here's how I made a Dockerfile
that mixes Python and Node:
FROM node:12 as frontend
COPY . /app
WORKDIR /app
RUN cd frontend && yarn install && yarn build
FROM python:3.8-slim
WORKDIR /app
RUN groupadd --gid 10001 app && useradd -g app --uid 10001 --shell /usr/sbin/nologin app
RUN chown app:app /tmp
RUN apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends \
gcc apt-transport-https python-dev
# Gotta try moving this to poetry instead!
COPY ./requirements.txt /app/requirements.txt
RUN pip install --upgrade --no-cache-dir -r requirements.txt
COPY . /app
COPY --from=frontend /app/frontend/build /app/frontend/build
USER app
ENV PORT=8000
EXPOSE $PORT
CMD uvicorn gitbusy.asgi:application --host 0.0.0.0 --port $PORT
If you're not familiar with it, the critical trick is on the first line where it builds some Node with as frontend
. That gives me a thing I can then copy from into the Python image with COPY --from=frontend /app/frontend/build /app/frontend/build
.
Now, at the very end, it starts a uvicorn
server with all the static .js
, index.html
, and favicon.ico
etc. available to uvicorn
which ultimately runs whitenoise
.
To run and build:
docker build . -t my_app docker run -t -i --rm --env-file .env -p 8000:8000 my_app
Now, opening http://localhost:8000/
is a production grade app that mixes Python (runtime) and JavaScript (static).
Heroku + Docker
Heroku says to create a heroku.yml
file and that makes sense but what didn't make sense is why I would add cmd
line in there when it's already in the Dockerfile
. The solution is simple: omit it. Here's what my final heroku.yml
file looks like:
build:
docker:
web: Dockerfile
Check in the heroku.yml
file and git push heroku master
and voila, it works!
To see a complete demo of all of this check out https://github.com/peterbe/gitbusy and https://gitbusy.herokuapp.com/