# Deploying to benstull.art — Cloudflare Pages + R2 Publishes the simulator as a fully-static site: the frontend on **Cloudflare Pages** (`benstull.art`, app at `/human-machine-strata`, apex redirects to it) and the video/audio on **R2** behind `static.benstull.art`. No origin server. Design: `docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md`. Plan: `docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md`. This project is **exempt from the flotilla/PPE/§9 pipeline** (operator-set) — it deploys directly to Cloudflare. Safety/hygiene rules still hold. ## Repeatable deploy (after the one-time setup below) Once the bucket/CORS/custom-domains + `wrangler login` exist (one-time, §1–3), every later deploy is one command (`deploy/cloudflare/deploy.sh`, or the Make targets): ```bash make deploy # frontend only: build + Pages deploy (fast — most deploys) make deploy-media # also re-sync all media to R2 (~2 min, parallel) — when media changed ./deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, skip Pages ``` The script rebuilds `dist/`, (optionally) parallel-uploads `dist-media/` to R2, and deploys Pages — printing the live URL. It checks `wrangler` auth first and fails loudly. Override defaults via env: `BUCKET`, `PROJECT`, `MEDIA_BASE`, `APP_PATH`, `BRANCH`, `JOBS`. The sections below document the one-time setup the script assumes. ## Prerequisites (operator gestures — need Cloudflare auth) `wrangler` is not installed in the dev box; the deploy runs from a machine with Cloudflare credentials. ```bash npm i -g wrangler wrangler login ``` `benstull.art` is already a Cloudflare zone (no nameserver migration). ## 1. Build the artifacts From the repo root (run as a MODULE — `tools/http.py` shadows stdlib `http` if run as a script): ```bash .venv/bin/python -m tools.build_static ``` Produces: - `dist/` — Pages output: `dist/human-machine-strata/` (frontend + baked `clips.json`/`ring.json`/`media-versions.json` + generated `config.js`) and a root `dist/_redirects` (apex → app path, and no-slash → slashed). - `dist-media/` — R2 upload tree, ~2.3 GB / 604 files, manifest-referenced only (clip bases + 154 transition morphs + 5 per-scale audio). No master/mezzanine sources. ## 2. R2 — bucket, CORS, custom domain, media Bucket name: **`human-experience-simulator-media`**. The default `wrangler login` OAuth token already has R2 + Pages + zone scopes (no separate API token needed). ```bash wrangler r2 bucket create human-experience-simulator-media # CORS — load-bearing (the WebGL dream shader taints without it). The wrangler CLI # wants its own schema, NOT the S3-style array; use the *.wrangler.json file: wrangler r2 bucket cors set human-experience-simulator-media --file deploy/cloudflare/r2-cors.wrangler.json # (deploy/cloudflare/r2-cors.json is the equivalent S3-style policy, for the # dashboard / S3 API; r2-cors.wrangler.json is the same rule in wrangler's # `{ "rules": [ { "allowed": {...} } ] }` form.) # Custom domain — wrangler CAN do this (no dashboard needed); needs the zone id. ZONE=$(curl -s -H "Authorization: Bearer $(grep '^oauth_token' ~/.wrangler/config/default.toml | cut -d'"' -f2)" \ "https://api.cloudflare.com/client/v4/zones?name=benstull.art" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])") wrangler r2 bucket domain add human-experience-simulator-media \ --domain static.benstull.art --zone-id "$ZONE" --min-tls 1.2 ``` **Media upload — there is NO `wrangler r2 bucket sync`.** Upload per-file with `wrangler r2 object put ... --remote`, setting content-type + immutable cache. The URLs are content-hash-versioned (`?v=`), so `immutable` is safe — a re-baked clip gets a new hash → new URL → cache busts. ~604 files / ~2.3 GB, sequential: ```bash WR=$(command -v wrangler); BUCKET=human-experience-simulator-media find dist-media -type f | while read -r f; do key="${f#dist-media/}" case "${f##*.}" in mp4) ct=video/mp4;; mp3) ct=audio/mpeg;; json) ct=application/json;; *) ct=application/octet-stream;; esac "$WR" r2 object put "$BUCKET/$key" --file "$f" --remote \ --content-type "$ct" --cache-control "public, max-age=31536000, immutable" done ``` (If you have `rclone` + an R2 S3 API token, `rclone copy dist-media :bucket` is much faster than the loop.) If you change the media base, rebuild with `--media-base https:///` so `config.js` matches. ## 3. Pages — deploy + domain + redirect ```bash wrangler pages project create human-experience-simulator --production-branch main wrangler pages deploy dist --project-name human-experience-simulator --branch main ``` Then **in the dashboard** (Workers & Pages → project → Custom domains) — there is no wrangler CLI for Pages custom domains: - Bind **`benstull.art`** to the project. (Allow a few minutes — a fresh bind serves `522` until the cert/routing provisions.) - The app serves at `benstull.art/human-machine-strata/`; the shipped `dist/_redirects` sends `/` and the no-slash form to it. ## 4. Verify before/after going live ```bash # CORS + Range on the media origin (after step 2) curl -sI -H "Origin: https://benstull.art" -H "Range: bytes=0-1" \ https://static.benstull.art//base.mp4 | grep -iE "access-control-allow-origin|content-range|accept-ranges" ``` Open `https://benstull.art/` → should redirect to `https://benstull.art/human-machine-strata/`. Turn the **Feel** knob to max and confirm the dream effect renders (no `SecurityError` in the console — that would mean CORS is misconfigured and the GL texture is tainted). The local static-build E2E (`simulator/e2e/tests/static-build.spec.ts`) exercises this same cross-origin path before you ship.