diff --git a/.gitignore b/.gitignore index eb3b221..536d360 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ simulator/static/review_audio.html # Editor annotation/comment-thread metadata (not project content) .threads/ + +# Static build outputs (tools/build_static.py) +/dist/ +/dist-media/ diff --git a/Makefile b/Makefile index d663205..ade5356 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: sim sim-local +.PHONY: sim sim-local deploy deploy-media # Prefer the project venv's interpreter (this box has no bare `python` on PATH), # fall back to python3, then python. @@ -9,3 +9,12 @@ sim: sim-local: $(PYTHON) -m uvicorn simulator.app:app --reload --port 8000 + +# Repeatable deploy to benstull.art (Cloudflare Pages + R2). One-time setup +# (wrangler login, bucket/CORS/domain) is in deploy/cloudflare/README.md. +# `deploy` is frontend-only (fast); `deploy-media` also re-syncs media to R2. +deploy: + ./deploy/cloudflare/deploy.sh + +deploy-media: + ./deploy/cloudflare/deploy.sh --media diff --git a/deploy/cloudflare/README.md b/deploy/cloudflare/README.md new file mode 100644 index 0000000..e345995 --- /dev/null +++ b/deploy/cloudflare/README.md @@ -0,0 +1,128 @@ +# 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-experience-simulator`, 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-experience-simulator/` (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-experience-simulator/`; 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-experience-simulator/`. 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. diff --git a/deploy/cloudflare/deploy.sh b/deploy/cloudflare/deploy.sh new file mode 100755 index 0000000..796f531 --- /dev/null +++ b/deploy/cloudflare/deploy.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Repeatable deploy of the HEF simulator to benstull.art (Cloudflare Pages + R2). +# +# deploy/cloudflare/deploy.sh # frontend only: build + Pages deploy (fast) +# deploy/cloudflare/deploy.sh --media # also re-sync all media to R2 (~2 min, parallel) +# deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, no Pages deploy +# +# Assumes the ONE-TIME setup is already done (see README.md): wrangler logged in, +# the R2 bucket + CORS + static.benstull.art custom domain, and the Pages project + +# benstull.art custom domain. This script only does the repeatable part. +# +# Override any of these via env: BUCKET, PROJECT, MEDIA_BASE, APP_PATH, BRANCH, JOBS. +set -euo pipefail + +BUCKET="${BUCKET:-human-experience-simulator-media}" +PROJECT="${PROJECT:-human-experience-simulator}" +MEDIA_BASE="${MEDIA_BASE:-https://static.benstull.art/}" +APP_PATH="${APP_PATH:-/human-experience-simulator}" +BRANCH="${BRANCH:-main}" +JOBS="${JOBS:-10}" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT="$REPO_ROOT/dist" +export MEDIA_OUT="$REPO_ROOT/dist-media" +PY="${PY:-$REPO_ROOT/.venv/bin/python}" +export WRANGLER="${WRANGLER:-$(command -v wrangler || echo "$HOME/.npm-global/bin/wrangler")}" + +do_media=0 +do_pages=1 +case "${1:-}" in + --media) do_media=1 ;; + --media-only) do_media=1; do_pages=0 ;; + "") ;; + *) echo "unknown flag: $1 (use --media or --media-only)"; exit 2 ;; +esac + +[ -x "$WRANGLER" ] || { command -v "$WRANGLER" >/dev/null || { echo "wrangler not found ($WRANGLER) — npm i -g wrangler"; exit 1; }; } +"$WRANGLER" whoami >/dev/null 2>&1 || { echo "not authenticated — run: $WRANGLER login"; exit 1; } + +echo "▶ build ($MEDIA_BASE)" +cd "$REPO_ROOT" +"$PY" -m tools.build_static --out "$OUT" --media-out "$MEDIA_OUT" \ + --media-base "$MEDIA_BASE" --app-path "$APP_PATH" | tail -1 + +if [ "$do_media" = 1 ]; then + echo "▶ R2 media sync → $BUCKET (parallel x$JOBS, no bulk-sync exists)" + export BUCKET + fails=$(find "$MEDIA_OUT" -type f -print0 \ + | xargs -0 -P "$JOBS" -I {} bash "$REPO_ROOT/deploy/cloudflare/r2-put-one.sh" {} \ + | tee /dev/stderr | grep -c '^FAIL ' || true) + [ "$fails" = 0 ] || { echo "✘ $fails media uploads failed"; exit 1; } + echo "✔ media synced" +fi + +if [ "$do_pages" = 1 ]; then + echo "▶ Pages deploy → $PROJECT" + "$WRANGLER" pages deploy "$OUT" --project-name "$PROJECT" --branch "$BRANCH" --commit-dirty=true | tail -3 +fi + +echo "✔ done — https://benstull.art$APP_PATH/" diff --git a/deploy/cloudflare/r2-cors.json b/deploy/cloudflare/r2-cors.json new file mode 100644 index 0000000..60834b5 --- /dev/null +++ b/deploy/cloudflare/r2-cors.json @@ -0,0 +1,9 @@ +[ + { + "AllowedOrigins": ["https://benstull.art"], + "AllowedMethods": ["GET", "HEAD"], + "AllowedHeaders": ["Range", "Content-Type"], + "ExposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"], + "MaxAgeSeconds": 86400 + } +] diff --git a/deploy/cloudflare/r2-cors.wrangler.json b/deploy/cloudflare/r2-cors.wrangler.json new file mode 100644 index 0000000..261340c --- /dev/null +++ b/deploy/cloudflare/r2-cors.wrangler.json @@ -0,0 +1,13 @@ +{ + "rules": [ + { + "allowed": { + "origins": ["https://benstull.art"], + "methods": ["GET", "HEAD"], + "headers": ["Range", "Content-Type"] + }, + "exposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"], + "maxAgeSeconds": 86400 + } + ] +} diff --git a/deploy/cloudflare/r2-put-one.sh b/deploy/cloudflare/r2-put-one.sh new file mode 100755 index 0000000..ae1068e --- /dev/null +++ b/deploy/cloudflare/r2-put-one.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Upload ONE file to R2 with the right content-type + immutable cache. Used by +# deploy.sh's parallel xargs (wrangler has no bulk sync). Reads BUCKET / MEDIA_OUT / +# WRANGLER from the environment; the single argument is the local file path. +set -u +f="$1" +key="${f#"$MEDIA_OUT"/}" +# Guard: if the prefix strip didn't fire (e.g. relative path vs absolute MEDIA_OUT), +# the key would wrongly keep a leading dir → wrong R2 path. Fail loudly, don't ship it. +if [ "$key" = "$f" ]; then + echo "FAIL $f (key strip failed — MEDIA_OUT='$MEDIA_OUT' is not a prefix; pass absolute paths)" + exit 1 +fi +case "${f##*.}" in + mp4) ct=video/mp4 ;; + mp3) ct=audio/mpeg ;; + json) ct=application/json ;; + *) ct=application/octet-stream ;; +esac +if "$WRANGLER" r2 object put "$BUCKET/$key" --file "$f" --remote \ + --content-type "$ct" --cache-control "public, max-age=31536000, immutable" >/dev/null 2>&1; then + echo "ok $key" +else + echo "FAIL $key" +fi diff --git a/docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md b/docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md new file mode 100644 index 0000000..f0630a1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md @@ -0,0 +1,706 @@ +# Cloudflare Static Publish — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish the simulator as a fully-static site (no origin server) to `benstull.art/human-experience-simulator` on Cloudflare Pages, with media on R2. + +**Architecture:** Port the only live server logic (`/api/alteration`, the delta=0 random pick) to client JS; serve the read-APIs as baked JSON; point media at a configurable R2 base with CORS; keep the existing graduated prefetch. A Python build script emits `dist/` (Pages) + a media sync tree (R2). The dev FastAPI server stays for localhost/authoring. + +**Tech Stack:** Vanilla JS (UMD modules, `node --test`), Python 3 build script (imports existing `simulator`/`player` builders), Playwright E2E, `wrangler` (Pages + R2), Cloudflare redirect rule. + +## Global Constraints + +- **Path:** app served at `/human-experience-simulator`; apex `benstull.art` **redirects** there. +- **Media base:** `https://static.benstull.art/` (R2 custom domain), public bucket. +- **CORS is load-bearing:** R2 must send `Access-Control-Allow-Origin: https://benstull.art`; `