Infra
This commit is contained in:
1 parent
ea1afaf833
commit
e0db317146
12 files changed
+444
-56
No files matched your search
@@ -5,3 +5,10 @@ __pycache__
|
|||||||
|
|
||||||
# The build input. The image ships the compiled bible/, not what it came from.
|
# The build input. The image ships the compiled bible/, not what it came from.
|
||||||
source/
|
source/
|
||||||
|
|
||||||
|
# Never in the image: credentials, and what only the deploy uses.
|
||||||
|
.env
|
||||||
|
dist/
|
||||||
|
infra/
|
||||||
|
.build/
|
||||||
|
deploy.sh
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Copy to .env (gitignored) and fill in. The tools in compose.yaml read these.
|
||||||
|
|
||||||
|
# Railway workspace token: railway.com/account/tokens, with the workspace
|
||||||
|
# selected. Not RAILWAY_TOKEN: the CLI takes that name as a project token.
|
||||||
|
RAILWAY_API_TOKEN=
|
||||||
|
|
||||||
|
# Cloudflare API token for the aerlinn.net DNS records: Zone.DNS Edit and
|
||||||
|
# Zone.Zone Read, on that zone only.
|
||||||
|
CLOUDFLARE_API_TOKEN=
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
source/
|
source/
|
||||||
bible/
|
bible/
|
||||||
|
dist/
|
||||||
|
.terraform/
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.backup
|
||||||
|
.env
|
||||||
|
.build/
|
||||||
|
tfplan
|
||||||
+3
-2
@@ -2,13 +2,14 @@
|
|||||||
FROM python:3.13-alpine
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
SITE_ROOTS=/srv/assets:/srv/bible \
|
SITE_ROOTS=/srv/assets:/srv/bible \
|
||||||
PORT=8080
|
PORT=8080
|
||||||
|
|
||||||
WORKDIR /srv
|
WORKDIR /srv
|
||||||
# Baked in so the image runs standalone; compose bind-mounts over all three
|
# Baked in so the image runs standalone; compose bind-mounts over all of them
|
||||||
# for live edits without a rebuild.
|
# for live edits without a rebuild.
|
||||||
COPY server.py /srv/server.py
|
COPY server.py worker.py /srv/
|
||||||
COPY assets/ /srv/assets/
|
COPY assets/ /srv/assets/
|
||||||
COPY bible/ /srv/bible/
|
COPY bible/ /srv/bible/
|
||||||
|
|
||||||
|
|||||||
+39
-4
@@ -1,3 +1,24 @@
|
|||||||
|
# The deploy tools are here too, pinned, so nothing gets installed on the
|
||||||
|
# host. They sit in the "tools" profile: `docker compose up -d` still starts
|
||||||
|
# only the site, and `docker compose run --rm terraform ...` /
|
||||||
|
# `... railway ...` runs them.
|
||||||
|
|
||||||
|
x-tool: &tool
|
||||||
|
profiles: [tools]
|
||||||
|
# As you, so what they write into the repo (.terraform/, state, lock files)
|
||||||
|
# is yours and not root's.
|
||||||
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
|
working_dir: /work
|
||||||
|
volumes:
|
||||||
|
- .:/work
|
||||||
|
environment:
|
||||||
|
# The Terraform provider reads RAILWAY_TOKEN; the CLI reads a workspace
|
||||||
|
# token only as RAILWAY_API_TOKEN (RAILWAY_TOKEN is a project token to it).
|
||||||
|
- RAILWAY_TOKEN=${RAILWAY_API_TOKEN:-}
|
||||||
|
- RAILWAY_API_TOKEN
|
||||||
|
- CLOUDFLARE_API_TOKEN
|
||||||
|
- HOME=/tmp
|
||||||
|
|
||||||
services:
|
services:
|
||||||
bible:
|
bible:
|
||||||
build: .
|
build: .
|
||||||
@@ -7,11 +28,25 @@ services:
|
|||||||
# Host port is overridable: BIBLE_PORT=9000 docker compose up -d
|
# Host port is overridable: BIBLE_PORT=9000 docker compose up -d
|
||||||
- "${BIBLE_PORT:-8085}:8080"
|
- "${BIBLE_PORT:-8085}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
# Live edits without rebuilding. server.py is mounted too: leaving it
|
# Live edits without rebuilding. server.py and worker.py are mounted
|
||||||
# baked into the image is how an old copy of it once kept running for
|
# too: leaving them baked into the image is how an old copy once kept
|
||||||
# days after the file on disk had changed. Drop these lines to serve the
|
# running for days after the file on disk had changed. Drop these lines
|
||||||
# copy baked into the image instead.
|
# to serve the copy baked into the image instead.
|
||||||
- ./server.py:/srv/server.py:ro
|
- ./server.py:/srv/server.py:ro
|
||||||
|
- ./worker.py:/srv/worker.py:ro
|
||||||
- ./assets:/srv/assets:ro
|
- ./assets:/srv/assets:ro
|
||||||
- ./bible:/srv/bible:ro
|
- ./bible:/srv/bible:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
terraform:
|
||||||
|
<<: *tool
|
||||||
|
image: hashicorp/terraform:1.16.3
|
||||||
|
|
||||||
|
railway:
|
||||||
|
<<: *tool
|
||||||
|
build: infra/railway-cli
|
||||||
|
image: railway-cli:5.57.12
|
||||||
|
# The CLI takes RAILWAY_TOKEN as a project token if it is set at all.
|
||||||
|
environment:
|
||||||
|
- RAILWAY_API_TOKEN
|
||||||
|
- HOME=/tmp
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publish the site to one environment: ./deploy.sh dev|prod
|
||||||
|
#
|
||||||
|
# Uploads the build context with Railway's CLI; Railway builds the Dockerfile
|
||||||
|
# (found on its own) and swaps the new deployment in once it is running.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
env=${1:?usage: $0 dev|prod}
|
||||||
|
case $env in
|
||||||
|
prod) environment=production ;;
|
||||||
|
dev) environment=dev ;;
|
||||||
|
*) echo "usage: $0 dev|prod" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
outputs=$(docker compose run --rm -T terraform -chdir=infra/railway output -json)
|
||||||
|
out() { python3 -c 'import json,sys; print(json.load(sys.stdin)[sys.argv[1]]["value"])' "$1" <<<"$outputs"; }
|
||||||
|
|
||||||
|
project=$(out project_id)
|
||||||
|
service=$(out service)
|
||||||
|
|
||||||
|
# Only what the Dockerfile copies. Uploading the repo would mean either
|
||||||
|
# leaving out bible/ (gitignored, and the CLI honors .gitignore) or sending
|
||||||
|
# .env and source/ along with it.
|
||||||
|
stage=.build
|
||||||
|
rm -rf "$stage"
|
||||||
|
mkdir "$stage"
|
||||||
|
cp -r Dockerfile .dockerignore server.py worker.py assets bible "$stage/"
|
||||||
|
|
||||||
|
docker compose run --rm -T railway up "$stage" --path-as-root --no-gitignore --ci \
|
||||||
|
--project "$project" --environment "$environment" --service "$service" \
|
||||||
|
--message "deploy.sh $(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo +dirty)"
|
||||||
|
|
||||||
|
rm -rf "$stage"
|
||||||
|
echo "deployed $env: https://$(python3 -c 'import json,sys; print(json.load(sys.stdin)["domains"]["value"][sys.argv[1]]["domain"])' "$environment" <<<"$outputs")"
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Infrastructure
|
||||||
|
|
||||||
|
The site on Railway: one project, `bible`, with a `production` and a `dev`
|
||||||
|
environment, and one service, `site`, that runs the repo's `Dockerfile` in
|
||||||
|
Virginia (`us-east4-eqdc4a`). Railway holds the certificates; `server.py` does
|
||||||
|
the routing there as it does locally. DNS stays on Cloudflare.
|
||||||
|
|
||||||
|
| env | Railway environment | URL |
|
||||||
|
|------|---------------------|-------------------------------|
|
||||||
|
| prod | `production` | https://bible-2.aerlinn.net |
|
||||||
|
| dev | `dev` | https://bible-dev.aerlinn.net |
|
||||||
|
|
||||||
|
```
|
||||||
|
railway/ project, environments, service, custom domains, and their
|
||||||
|
CNAME + TXT records on Cloudflare
|
||||||
|
railway-cli/ Railway's CLI as a pinned image (theirs is x86-only)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
Nothing to install but Docker. Terraform and Railway's CLI are services in
|
||||||
|
`compose.yaml` (profile `tools`), reading tokens from `.env`. From the repo
|
||||||
|
root:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose run --rm terraform -chdir=infra/railway plan
|
||||||
|
docker compose run --rm railway status
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit `.terraform.lock.hcl`; it pins the provider builds.
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
1. **Tokens.** A Railway workspace token (account settings -> Tokens, with the
|
||||||
|
workspace selected; a project token cannot make projects) and the
|
||||||
|
Cloudflare token. `cp .env.example .env` and fill both in.
|
||||||
|
|
||||||
|
2. **Project and domains.**
|
||||||
|
```
|
||||||
|
docker compose run --rm terraform -chdir=infra/railway init
|
||||||
|
docker compose run --rm terraform -chdir=infra/railway apply
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Deploy**, below. Railway issues each certificate once it sees the CNAME;
|
||||||
|
the first HTTPS requests can fail for a minute or two until then.
|
||||||
|
|
||||||
|
## Deploying
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 utils/compile_bible.py # only when source/ changes
|
||||||
|
./deploy.sh dev
|
||||||
|
./deploy.sh prod
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy.sh` copies what the Dockerfile needs into `.build/` and uploads that
|
||||||
|
with `railway up`; Railway builds it and switches over when the new container
|
||||||
|
is running. Not the repo itself: the CLI skips gitignored paths, which
|
||||||
|
includes `bible/`, and telling it not to would send `.env` and `source/` too.
|
||||||
|
The build logs stream to the terminal, and the dashboard has them as well.
|
||||||
|
|
||||||
|
Terraform never deploys, and the provider cannot set anything Railway calls
|
||||||
|
config-as-code (healthchecks, start commands); Railway's defaults do.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
Local: `infra/railway/terraform.tfstate`, gitignored. Back it up. If it is
|
||||||
|
lost, nothing on Railway goes away; `terraform import` each resource back
|
||||||
|
(IDs from the dashboard URLs; the Cloudflare records as `<zone_id>/<id>`).
|
||||||
|
|
||||||
|
## Cost
|
||||||
|
|
||||||
|
Hobby plan: $5 a month, which includes $5 of usage. The service idles at a
|
||||||
|
few tens of MB and next to no CPU in each environment ($10 per GB-month of
|
||||||
|
memory, $20 per vCPU-month), so both fit in the included usage. Egress is
|
||||||
|
$0.05/GB, about $0.0001 per new reader (the whole library, ~2.2 MB).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Railway's CLI, pinned. Their own image is x86-only; the release binaries are
|
||||||
|
# static, so this just fetches the one for the platform being built.
|
||||||
|
FROM alpine:3.22
|
||||||
|
|
||||||
|
ARG VERSION=5.57.12
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
RUN case "$TARGETARCH" in \
|
||||||
|
arm64) triple=aarch64-unknown-linux-musl ;; \
|
||||||
|
amd64) triple=x86_64-unknown-linux-musl ;; \
|
||||||
|
*) echo "unsupported: $TARGETARCH" >&2; exit 1 ;; \
|
||||||
|
esac \
|
||||||
|
&& wget -qO- "https://github.com/railwayapp/cli/releases/download/v$VERSION/railway-v$VERSION-$triple.tar.gz" \
|
||||||
|
| tar -xz -C /usr/local/bin railway \
|
||||||
|
&& railway --version
|
||||||
|
|
||||||
|
ENTRYPOINT ["railway"]
|
||||||
Generated
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# This file is maintained automatically by "terraform init".
|
||||||
|
# Manual edits may be lost in future updates.
|
||||||
|
|
||||||
|
provider "registry.terraform.io/cloudflare/cloudflare" {
|
||||||
|
version = "5.25.0"
|
||||||
|
constraints = "~> 5.25"
|
||||||
|
hashes = [
|
||||||
|
"h1:uOE/ctTNSIjD6mEvrKz81eLB7hVfAAa8TsMbDhJlHts=",
|
||||||
|
"zh:403d477c0e4c05a9f9886ef7e750dcc3488be7cd2210207e98b4a731bc499159",
|
||||||
|
"zh:4a2a4659c3273ddb1437a34865eac21823a117557b050786ba510ac5f34047cf",
|
||||||
|
"zh:6e4a2f6ed105b5353c7b56962d00f923873dd68af6509b9b9f595057edba1915",
|
||||||
|
"zh:aadd2051c1eb01e7b518d4808de7f5192b2ecbc841d70924785ee3385a889b9e",
|
||||||
|
"zh:ab0322bcb0d4e465f8603b39f7e743c836b22ae775c03ed677ad999a6a1b8bf4",
|
||||||
|
"zh:c47e38911c92e0b2ffe7be1d36d96b79dd2af79bc61745d6afe5a730cae9f287",
|
||||||
|
"zh:df3f13ae57104ce2f7619cc6c050ab90a5a3299470cdbd2ab8dd3809b84edcb8",
|
||||||
|
"zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
|
||||||
|
"zh:fb7de9d22b02367036c146bd683c8968ec4bbb56aca3185fa84146d118a2bf96",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "registry.terraform.io/terraform-community-providers/railway" {
|
||||||
|
version = "0.6.2"
|
||||||
|
constraints = "~> 0.6.2"
|
||||||
|
hashes = [
|
||||||
|
"h1:4Az1FULORPb4P5ZtZ6D/tR6PcqE1AY+hCJW+sJuXjxs=",
|
||||||
|
"zh:2165a426b2e346c2debb05517e3993f87253ddcdf145d94401ea2ab9178d35ab",
|
||||||
|
"zh:2f3f0f3423564c88ada05238f136034fc35634fc7978efc503b984ba35e83724",
|
||||||
|
"zh:3bb0618cddccca0d0c690044f7338e4cd103b45114ffbcca8769e864a1b2554b",
|
||||||
|
"zh:50fd5dc5cf749d8ecd10e5e4ee5cef04b541beb522aab6d4e1845ee812e0c088",
|
||||||
|
"zh:6a52b2c60121a695151d6a91e61c3fecabc0a40bf75d6b530d828eef0dc0619d",
|
||||||
|
"zh:7356f98cef110a5ca1a261fddde4b53c74b6a22848bdca642e2094f7708b3626",
|
||||||
|
"zh:7b47792310e502d0a0b4427133b161114dce2323bc61bf40dc6c4d67858b7629",
|
||||||
|
"zh:7e8740d239d76a869e0d8eb5dc6c1ec62459603ea6a057333006f8de1c12e2f0",
|
||||||
|
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
|
||||||
|
"zh:99d7a179fe16211beb790e19a8f65d570a23ed4f6b752b5bd6d073195fc4cf9d",
|
||||||
|
"zh:9f31f26d0d93067fca796a718a9b39ebfb1fce4d24e382c215f33e0dde7b3420",
|
||||||
|
"zh:adb29c332488b29cc483f8cac00a0cc54cacd4cfe5bbd818a55a1c1b4394ebcd",
|
||||||
|
"zh:d16148e673900550273cbbb12f7dbd7f96ec0cfaa863ec93ec5b108258415637",
|
||||||
|
"zh:da40431bcd65e85bec7f301611cad5b4b772dae250e9622556072b6a3a7503b6",
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# The site on Railway: one project, with an environment each for prod and dev,
|
||||||
|
# and one service that runs the repo's Dockerfile. server.py does the routing,
|
||||||
|
# as it does locally; Railway holds the certificates.
|
||||||
|
#
|
||||||
|
# Terraform makes the project, the service and the domains, and puts their
|
||||||
|
# records on Cloudflare. It never deploys: deploy.sh uploads the site with
|
||||||
|
# Railway's CLI. State is local (terraform.tfstate here, gitignored).
|
||||||
|
|
||||||
|
terraform {
|
||||||
|
required_version = ">= 1.10"
|
||||||
|
required_providers {
|
||||||
|
railway = { source = "terraform-community-providers/railway", version = "~> 0.6.2" }
|
||||||
|
cloudflare = { source = "cloudflare/cloudflare", version = "~> 5.25" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reads RAILWAY_TOKEN (compose.yaml sets it from RAILWAY_API_TOKEN in .env):
|
||||||
|
# a workspace token.
|
||||||
|
provider "railway" {}
|
||||||
|
|
||||||
|
# Reads CLOUDFLARE_API_TOKEN: DNS edit and zone read, on aerlinn.net only.
|
||||||
|
provider "cloudflare" {}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
zone = "aerlinn.net"
|
||||||
|
# Railway Metal, Virginia.
|
||||||
|
region = "us-east4-eqdc4a"
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "railway_project" "bible" {
|
||||||
|
name = "bible"
|
||||||
|
description = "Offline Bible reader"
|
||||||
|
default_environment = {
|
||||||
|
name = "production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "railway_environment" "dev" {
|
||||||
|
name = "dev"
|
||||||
|
project_id = railway_project.bible.id
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "railway_service" "site" {
|
||||||
|
name = "site"
|
||||||
|
project_id = railway_project.bible.id
|
||||||
|
regions = [{
|
||||||
|
region = local.region
|
||||||
|
num_replicas = 1
|
||||||
|
}]
|
||||||
|
|
||||||
|
# Made after both environments, so it is in both.
|
||||||
|
depends_on = [railway_environment.dev]
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
environments = {
|
||||||
|
production = {
|
||||||
|
id = railway_project.bible.default_environment.id
|
||||||
|
domain = "bible-2.${local.zone}"
|
||||||
|
}
|
||||||
|
dev = {
|
||||||
|
id = railway_environment.dev.id
|
||||||
|
domain = "bible-dev.${local.zone}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "railway_custom_domain" "site" {
|
||||||
|
for_each = local.environments
|
||||||
|
|
||||||
|
domain = each.value.domain
|
||||||
|
environment_id = each.value.id
|
||||||
|
service_id = railway_service.site.id
|
||||||
|
}
|
||||||
|
|
||||||
|
data "cloudflare_zone" "site" {
|
||||||
|
filter = {
|
||||||
|
name = local.zone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# DNS only, not proxied: Railway issues the certificate itself and has to see
|
||||||
|
# the CNAME as it is.
|
||||||
|
resource "cloudflare_dns_record" "site" {
|
||||||
|
for_each = railway_custom_domain.site
|
||||||
|
|
||||||
|
zone_id = data.cloudflare_zone.site.id
|
||||||
|
name = each.value.domain
|
||||||
|
type = "CNAME"
|
||||||
|
content = each.value.dns_record_value
|
||||||
|
ttl = 1
|
||||||
|
proxied = false
|
||||||
|
comment = "terraform: railway bible ${each.key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Railway's proof that the domain is ours.
|
||||||
|
resource "cloudflare_dns_record" "verification" {
|
||||||
|
for_each = railway_custom_domain.site
|
||||||
|
|
||||||
|
zone_id = data.cloudflare_zone.site.id
|
||||||
|
name = "${each.value.verification_host_label}.${each.value.zone}"
|
||||||
|
type = "TXT"
|
||||||
|
content = "\"${each.value.verification_record_value}\""
|
||||||
|
ttl = 1
|
||||||
|
comment = "terraform: railway bible ${each.key} verification"
|
||||||
|
}
|
||||||
|
|
||||||
|
output "project_id" {
|
||||||
|
value = railway_project.bible.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "service" {
|
||||||
|
value = railway_service.site.name
|
||||||
|
}
|
||||||
|
|
||||||
|
output "domains" {
|
||||||
|
value = {
|
||||||
|
for name, d in railway_custom_domain.site : name => {
|
||||||
|
domain = d.domain
|
||||||
|
host_label = d.host_label
|
||||||
|
zone = d.zone
|
||||||
|
target = d.dns_record_value
|
||||||
|
verification_host = d.verification_host_label
|
||||||
|
verification = d.verification_record_value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
|
||||||
import io
|
import io
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
import signal
|
import signal
|
||||||
@@ -13,59 +11,26 @@ import sys
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from worker import WORKER, stamp_worker, walk
|
||||||
|
|
||||||
NO_CACHE_NAMES = {"sw.js"}
|
NO_CACHE_NAMES = {"sw.js"}
|
||||||
NO_CACHE_EXTS = {".html", ".njk", ".json", ".webmanifest"}
|
NO_CACHE_EXTS = {".html", ".njk", ".json", ".webmanifest"}
|
||||||
|
|
||||||
BOOT_PAGE = "/boot.html"
|
BOOT_PAGE = "/boot.html"
|
||||||
|
|
||||||
WORKER = "sw.js"
|
_stamped = (None, None)
|
||||||
|
|
||||||
VERSION_LINE = "const VERSION = 'dev';"
|
def cached_worker(roots):
|
||||||
LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';"
|
# Stamping hashes every file the worker lists, 11 MB of them, and browsers
|
||||||
LIBRARY_LINE = "const LIBRARY = [];"
|
# ask for sw.js on almost every load. Stats are cheap, so redo the stamp
|
||||||
|
# only when one moves: often under compose, never in a baked image.
|
||||||
def walk(roots):
|
global _stamped
|
||||||
found = {}
|
key = tuple((rel, st.st_size, st.st_mtime_ns)
|
||||||
for root in roots:
|
for rel, st in ((rel, os.stat(path))
|
||||||
for dirpath, _dirs, names in os.walk(root):
|
for rel, path in sorted(walk(roots).items())))
|
||||||
for name in sorted(names):
|
if _stamped[0] != key:
|
||||||
path = os.path.join(dirpath, name)
|
_stamped = (key, stamp_worker(roots))
|
||||||
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
return _stamped[1]
|
||||||
found.setdefault(rel, path)
|
|
||||||
return found
|
|
||||||
|
|
||||||
def digest(files):
|
|
||||||
out = hashlib.sha256()
|
|
||||||
for rel in sorted(files):
|
|
||||||
st = os.stat(files[rel])
|
|
||||||
out.update(("%s\0%d\0%d\0" % (rel, st.st_size, st.st_mtime_ns)).encode())
|
|
||||||
return out.hexdigest()[:12]
|
|
||||||
|
|
||||||
def page_url(rel):
|
|
||||||
if rel == "index.html":
|
|
||||||
return "./"
|
|
||||||
return "./" + (rel[:-len(".html")] if rel.endswith(".html") else rel)
|
|
||||||
|
|
||||||
def stamp_worker(roots):
|
|
||||||
chrome = walk(roots[:1])
|
|
||||||
library = {rel: path for rel, path in walk(roots).items() if rel not in chrome}
|
|
||||||
|
|
||||||
src = chrome.pop(WORKER, None)
|
|
||||||
if src is None:
|
|
||||||
return None
|
|
||||||
with open(src, encoding="utf-8") as fh:
|
|
||||||
body = fh.read()
|
|
||||||
|
|
||||||
for line, value in (
|
|
||||||
(VERSION_LINE, "const VERSION = '%s';" % digest(chrome)),
|
|
||||||
(LIBRARY_VERSION_LINE, "const LIBRARY_VERSION = '%s';" % digest(library)),
|
|
||||||
(LIBRARY_LINE, "const LIBRARY = %s;"
|
|
||||||
% json.dumps(sorted(page_url(rel) for rel in library), indent=2)),
|
|
||||||
):
|
|
||||||
if line not in body:
|
|
||||||
raise RuntimeError("%s: expected to find %r to stamp" % (WORKER, line))
|
|
||||||
body = body.replace(line, value, 1)
|
|
||||||
return body.encode("utf-8")
|
|
||||||
|
|
||||||
class Handler(SimpleHTTPRequestHandler):
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
extensions_map = {
|
extensions_map = {
|
||||||
@@ -106,7 +71,7 @@ class Handler(SimpleHTTPRequestHandler):
|
|||||||
|
|
||||||
def send_worker(self):
|
def send_worker(self):
|
||||||
try:
|
try:
|
||||||
body = stamp_worker(self.roots)
|
body = cached_worker(self.roots)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
self.log_message("stamp failed: %s", err)
|
self.log_message("stamp failed: %s", err)
|
||||||
self.send_error(500, "service worker unavailable")
|
self.send_error(500, "service worker unavailable")
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Stamping for the service worker.
|
||||||
|
|
||||||
|
The dev server stamps sw.js on every request; the deploy stamps it once into
|
||||||
|
the uploaded copy. Both import this, because a digest computed two different
|
||||||
|
ways is a worker that thinks its caches are current when they are not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
WORKER = "sw.js"
|
||||||
|
|
||||||
|
VERSION_LINE = "const VERSION = 'dev';"
|
||||||
|
LIBRARY_VERSION_LINE = "const LIBRARY_VERSION = 'dev';"
|
||||||
|
LIBRARY_LINE = "const LIBRARY = [];"
|
||||||
|
|
||||||
|
CHUNK = 1 << 20
|
||||||
|
|
||||||
|
def walk(roots):
|
||||||
|
found = {}
|
||||||
|
for root in roots:
|
||||||
|
for dirpath, _dirs, names in os.walk(root):
|
||||||
|
for name in sorted(names):
|
||||||
|
path = os.path.join(dirpath, name)
|
||||||
|
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
||||||
|
found.setdefault(rel, path)
|
||||||
|
return found
|
||||||
|
|
||||||
|
def digest(files):
|
||||||
|
# Contents, not size and mtime: a checkout, a rebuild and a rsync all move
|
||||||
|
# mtimes without changing a byte, and each one of those would otherwise
|
||||||
|
# throw away every cache on every device for nothing.
|
||||||
|
out = hashlib.sha256()
|
||||||
|
for rel in sorted(files):
|
||||||
|
out.update(rel.encode("utf-8") + b"\0")
|
||||||
|
with open(files[rel], "rb") as fh:
|
||||||
|
for chunk in iter(lambda: fh.read(CHUNK), b""):
|
||||||
|
out.update(chunk)
|
||||||
|
out.update(b"\0")
|
||||||
|
return out.hexdigest()[:12]
|
||||||
|
|
||||||
|
def page_url(rel):
|
||||||
|
if rel == "index.html":
|
||||||
|
return "./"
|
||||||
|
return "./" + (rel[:-len(".html")] if rel.endswith(".html") else rel)
|
||||||
|
|
||||||
|
def stamp_worker(roots):
|
||||||
|
chrome = walk(roots[:1])
|
||||||
|
library = {rel: path for rel, path in walk(roots).items() if rel not in chrome}
|
||||||
|
|
||||||
|
src = chrome.pop(WORKER, None)
|
||||||
|
if src is None:
|
||||||
|
return None
|
||||||
|
with open(src, encoding="utf-8") as fh:
|
||||||
|
body = fh.read()
|
||||||
|
|
||||||
|
for line, value in (
|
||||||
|
(VERSION_LINE, "const VERSION = '%s';" % digest(chrome)),
|
||||||
|
(LIBRARY_VERSION_LINE, "const LIBRARY_VERSION = '%s';" % digest(library)),
|
||||||
|
(LIBRARY_LINE, "const LIBRARY = %s;"
|
||||||
|
% json.dumps(sorted(page_url(rel) for rel in library), indent=2)),
|
||||||
|
):
|
||||||
|
if line not in body:
|
||||||
|
raise RuntimeError("%s: expected to find %r to stamp" % (WORKER, line))
|
||||||
|
body = body.replace(line, value, 1)
|
||||||
|
return body.encode("utf-8")
|
||||||
Reference in new issue
Block a user