The sentence helper is an optional download (Settings → Sentence helper): Gemma 4 E2B in Google's web build, run by Google's LiteRT-LM web runtime on the graphics chip, in a worker. ✨ beside the message turns the words chosen into three sentences, fitting what the partner said if it was typed in, or suggests four replies when no words are chosen yet. The suggestions take the word predictions' place, at their height, so no cell moves (SPEC §3); the person chooses one and speaks it. Nothing is spoken by itself, and nothing typed leaves the device. - ai_helper.rs: the catalogue (aac-ai-index/1), prompts, whether a device can run it (a WebGPU hardware adapter, never the processor), download, continue, remove and re-check, and suggestions that a later request supersedes. The model's parts (64 MiB, SHA-256 checked) and the one runtime build this browser runs live in the model store; the worker streams the parts from there into GPU memory. - third_party/litert-lm: @litert-lm/core 0.17.1 vendored and pinned (npm integrity, each file's size and SHA-256), with our worker bundled by esbuild 0.28.2; the runtime's CDN default is emptied. - tools/ai: build_ai.py (the pinned model → parts and catalogue), check_packs.py, languages.json (English, Spanish, German and Hindi ready; Swahili not yet), and eval/: the 20 exchanges, a harness that runs the app's own prompts (a test keeps them word for word), and the judged answers. - Measured: Chrome on an M5 Max, 0.4-0.7 s an answer. A real iPad Air (M3) in Safari 27: 101 s to download and check 2 GB, about 80 s for the first start, 5.9 s for later starts, 0.8-1.4 s an answer. Co-Authored-By: Claude Opus 5.5 <[email protected]>
221 lines
12 KiB
Shell
Executable file
221 lines
12 KiB
Shell
Executable file
#!/usr/bin/env bash
|
|
# ============================================================================
|
|
# prerender-static.sh — build aac.transaction.science as a self-contained
|
|
# static site.
|
|
#
|
|
# AAC ships as a static CSR-hydrate SPA, but it's a Leptos SSR app: the
|
|
# index.html the browser loads is the SSR server's *rendered* output (it carries
|
|
# the hydration island markers and the pre-rendered board). A plain
|
|
# `cargo leptos build` never emits that index.html, and it doesn't copy the
|
|
# PWA assets that live in `static/`. This script closes both gaps and makes the
|
|
# result safe to cache forever:
|
|
#
|
|
# build -> target/site/{pkg/, icons/, symbols/} (cargo-leptos)
|
|
# render -> target/site/index.html (capture SSR `/`)
|
|
# hash -> pkg/aac-web.<sha256:12>.{js,wasm,css} (and rewrite references)
|
|
# vendor -> target/site/vendor/mediapipe-<ver>-<rev>/ (eye gaze, same origin)
|
|
# worker -> target/site/sw.js (static/sw.js + precache list)
|
|
#
|
|
# Output: a self-contained `target/site/` ready to rsync to prod. Deploy it with
|
|
# no_cache_bust — the names are already content-hashed, and the service worker
|
|
# lists them. Idempotent.
|
|
#
|
|
# Hosting under a sub-path, or under another name, is a build-time choice; see
|
|
# AAC_BASE_PATH, AAC_SW_URL, AAC_APP_TITLE and AAC_HIDE_ENERGY in
|
|
# crates/aac-web/src/app.rs. This script builds the root-mounted default.
|
|
# ============================================================================
|
|
set -euo pipefail
|
|
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
PORT="${AAC_PRERENDER_PORT:-39207}"
|
|
SITE="target/site"
|
|
CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/aac-rs"
|
|
|
|
# wasm-bindgen's CLI must match the crate version exactly (Cargo.toml pins it).
|
|
# Use a private copy of the right version rather than the machine's global one.
|
|
WBG_VER="$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/version = |"/, ""); print; exit}' Cargo.lock)"
|
|
if ! wasm-bindgen --version 2>/dev/null | grep -qx "wasm-bindgen $WBG_VER"; then
|
|
WBG_ROOT="$CACHE/wasm-bindgen-$WBG_VER"
|
|
if [ ! -x "$WBG_ROOT/bin/wasm-bindgen" ]; then
|
|
echo "▸ installing wasm-bindgen-cli $WBG_VER into $WBG_ROOT"
|
|
cargo install --quiet --locked --root "$WBG_ROOT" wasm-bindgen-cli --version "$WBG_VER"
|
|
fi
|
|
export PATH="$WBG_ROOT/bin:$PATH"
|
|
fi
|
|
|
|
echo "▸ cargo leptos build --release"
|
|
rm -rf "$SITE/pkg"
|
|
cargo leptos build --release
|
|
find "$SITE/pkg" -name '*.d.ts' -delete
|
|
# Symbol sprites and their licences (cargo-leptos copies public/; a clean copy
|
|
# drops sprites of earlier builds, whose names carry another hash).
|
|
rm -rf "$SITE/symbols"
|
|
cp -R crates/aac-web/public/symbols "$SITE/symbols"
|
|
# Language packs for word prediction (data/lang: content-hashed, with their
|
|
# licence) under models/lang/, where the app's model store downloads a
|
|
# language's pack once, verifies it and keeps it (aac-web lang_packs.rs).
|
|
rm -rf "$SITE/models/lang"
|
|
mkdir -p "$SITE/models/lang"
|
|
cp data/lang/*.bin data/lang/index.json data/lang/LICENSE.md "$SITE/models/lang/"
|
|
|
|
echo "▸ starting SSR server on 127.0.0.1:$PORT to prerender /"
|
|
LOG="$(mktemp)"
|
|
LEPTOS_OUTPUT_NAME=aac-web \
|
|
LEPTOS_SITE_ROOT="$SITE" \
|
|
LEPTOS_SITE_PKG_DIR=pkg \
|
|
LEPTOS_SITE_ADDR="127.0.0.1:$PORT" \
|
|
LEPTOS_ENV=PROD \
|
|
target/release/aac-web > "$LOG" 2>&1 &
|
|
SRV=$!
|
|
trap 'kill "$SRV" 2>/dev/null || true' EXIT
|
|
|
|
# Wait for the listener (up to ~30s).
|
|
ready=false
|
|
for _ in $(seq 1 120); do
|
|
if curl -sf "http://127.0.0.1:$PORT/" -o /dev/null 2>/dev/null; then ready=true; break; fi
|
|
sleep 0.25
|
|
done
|
|
if ! $ready; then
|
|
echo "✗ SSR server did not come up; log:" >&2; tail -5 "$LOG" >&2; exit 1
|
|
fi
|
|
|
|
echo "▸ capturing rendered / -> $SITE/index.html"
|
|
curl -sf "http://127.0.0.1:$PORT/" -o "$SITE/index.html"
|
|
|
|
kill "$SRV" 2>/dev/null || true
|
|
trap - EXIT
|
|
|
|
# Sanity-check the capture is the hydrating SSR shell, not an error page.
|
|
test -s "$SITE/index.html"
|
|
grep -q 'mod.hydrate' "$SITE/index.html" || { echo "✗ captured index.html is not the hydrating shell" >&2; exit 1; }
|
|
grep -q '<title>AAC' "$SITE/index.html" || { echo "✗ captured index.html missing expected title" >&2; exit 1; }
|
|
|
|
echo "▸ content-hashing pkg/"
|
|
hash12() { shasum -a 256 "$1" | cut -c1-12; }
|
|
# Portable in-place sed (BSD and GNU disagree about -i): sed_i <args…> <file>
|
|
sed_i() { local f="${!#}"; sed "${@:1:$#-1}" "$f" > "$f.tmp" && mv "$f.tmp" "$f"; }
|
|
P="$SITE/pkg"
|
|
[ -f "$P/aac-web_bg.wasm" ] || mv "$P/aac-web.wasm" "$P/aac-web_bg.wasm"
|
|
W="$(hash12 "$P/aac-web_bg.wasm")"; mv "$P/aac-web_bg.wasm" "$P/aac-web.$W.wasm"
|
|
C="$(hash12 "$P/aac-web.css")"; mv "$P/aac-web.css" "$P/aac-web.$C.css"
|
|
# The glue may name its wasm; point it at the hashed file before hashing the glue.
|
|
sed_i -e "s#aac-web_bg\\.wasm#aac-web.$W.wasm#g" -e "s#aac-web\\.wasm#aac-web.$W.wasm#g" "$P/aac-web.js"
|
|
# wasm-bindgen names the snippet directory after the crate, not its contents,
|
|
# so a snippet keeps its URL from build to build, and Cloudflare gives every .js
|
|
# a 4-hour browser cache whatever Caddy sends. A returning visitor would then
|
|
# pair a new aac-web.js with an old snippet, and the app would not start (seen
|
|
# on 2026-09-30). Name each snippet directory by its contents too, before
|
|
# aac-web.js (whose imports name it) is hashed.
|
|
for d in "$P"/snippets/*/; do
|
|
d="${d%/}"; name="$(basename "$d")"
|
|
S="$(cd "$d" && find . -type f | LC_ALL=C sort | while IFS= read -r f; do printf '%s %s\n' "$f" "$(shasum -a 256 "$f" | cut -c1-64)"; done | shasum -a 256 | cut -c1-12)"
|
|
mv "$d" "$P/snippets/$name-$S"
|
|
# Only where the script loads it (`from "…"`, `import("…")`): the keys of
|
|
# the WebAssembly import object keep the old path, which the .wasm names.
|
|
sed_i -e "s#\(from *[\"']\)\./snippets/$name/#\1./snippets/$name-$S/#g" \
|
|
-e "s#\(import( *[\"']\)\./snippets/$name/#\1./snippets/$name-$S/#g" "$P/aac-web.js"
|
|
grep -q "snippets/$name-$S/" "$P/aac-web.js" || { echo "✗ aac-web.js does not import snippets/$name-$S" >&2; exit 1; }
|
|
if grep -qE "(from *[\"']|import\( *[\"'])\./snippets/$name/" "$P/aac-web.js"; then
|
|
echo "✗ aac-web.js still loads snippets/$name by its old name" >&2; exit 1
|
|
fi
|
|
done
|
|
J="$(hash12 "$P/aac-web.js")"; mv "$P/aac-web.js" "$P/aac-web.$J.js"
|
|
sed_i \
|
|
-e "s#/pkg/aac-web_bg\\.wasm#/pkg/aac-web.$W.wasm#g" \
|
|
-e "s#/pkg/aac-web\\.wasm#/pkg/aac-web.$W.wasm#g" \
|
|
-e "s#/pkg/aac-web\\.js#/pkg/aac-web.$J.js#g" \
|
|
-e "s#/pkg/aac-web\\.css#/pkg/aac-web.$C.css#g" \
|
|
"$SITE/index.html"
|
|
# Leptos's hydration loader builds its URLs from arguments:
|
|
# (root, pkg_path, output_name, wasm_output_name)
|
|
LOADER='("", "pkg", "aac-web", "aac-web")'
|
|
grep -qF "$LOADER" "$SITE/index.html" || { echo "✗ hydration loader call not found — has Leptos changed its loader?" >&2; exit 1; }
|
|
sed_i "s#(\"\", \"pkg\", \"aac-web\", \"aac-web\")#(\"\", \"pkg\", \"aac-web.$J\", \"aac-web.$W\")#" "$SITE/index.html"
|
|
for f in "aac-web.$W.wasm" "aac-web.$J.js" "aac-web.$C.css" "\"aac-web.$J\", \"aac-web.$W\""; do
|
|
grep -q "$f" "$SITE/index.html" || { echo "✗ index.html does not reference $f" >&2; exit 1; }
|
|
done
|
|
|
|
# Eye gaze loads MediaPipe from vendor/mediapipe-<version>-<revision>/ on this
|
|
# origin (crates/aac-web/src/bindings/mediapipe_init.js), so turning on the
|
|
# camera makes no third-party request and the app stays cross-origin isolated.
|
|
rm -rf "$SITE/vendor"
|
|
bash third_party/mediapipe/vendor.sh "$SITE" "$CACHE"
|
|
MP_DIR="$(cd "$SITE/vendor" && echo mediapipe-*)"
|
|
[ -d "$SITE/vendor/$MP_DIR" ] || { echo "✗ vendor.sh left no vendor/mediapipe-* directory" >&2; exit 1; }
|
|
|
|
# On-device voices, downloaded only when the person asks (neural_voice.rs):
|
|
# the Moonshine WASM runtime, vendored and pinned like MediaPipe, and the
|
|
# voice packs, built outside git by tools/voices/build_voices.py, under
|
|
# voices/ (cloned, not copied, on APFS), once tools/voices/check_packs.py
|
|
# has matched them to the committed catalogue. A build without them must say
|
|
# so (AAC_VOICES=none): a deploy would otherwise delete the voices it serves.
|
|
rm -rf "$SITE/voices"
|
|
if [ "${AAC_VOICES:-}" = "none" ]; then
|
|
echo "▸ AAC_VOICES=none: no better voices in this build; the app offers the device's voices only"
|
|
else
|
|
bash third_party/moonshine-wasm/vendor.sh "$SITE" "$CACHE"
|
|
VOICES="${AAC_VOICES_DIR:-$(cd ../../.. && pwd)/aac-voice-packs}"
|
|
python3 tools/voices/check_packs.py "$VOICES" \
|
|
|| { echo "✗ build the packs (tools/voices/build_voices.py), or set AAC_VOICES=none to build without better voices" >&2; exit 1; }
|
|
cp -cR "$VOICES" "$SITE/voices" 2>/dev/null || cp -R "$VOICES" "$SITE/voices"
|
|
# index.json and LICENSES.md keep their names from one catalogue to the next,
|
|
# and the builder stamps every file with one fixed time: give these two this
|
|
# build's own, or rsync's size-and-time check would keep prod's old pointer,
|
|
# to a catalogue its --delete has just removed.
|
|
touch "$SITE/voices/index.json" "$SITE/voices/LICENSES.md"
|
|
fi
|
|
# The sentence helper, downloaded only when the person asks (ai_helper.rs):
|
|
# Google's LiteRT-LM web runtime, vendored and pinned like Moonshine, and the
|
|
# model's parts, built outside git by tools/ai/build_ai.py, under ai/ (cloned,
|
|
# not copied, on APFS), once tools/ai/check_packs.py has matched them to the
|
|
# committed catalogue. A build without them must say so (AAC_AI=none).
|
|
rm -rf "$SITE/ai"
|
|
if [ "${AAC_AI:-}" = "none" ]; then
|
|
echo "▸ AAC_AI=none: no sentence helper in this build"
|
|
else
|
|
bash third_party/litert-lm/vendor.sh "$SITE" "$CACHE"
|
|
AI="${AAC_AI_DIR:-$(cd ../../.. && pwd)/aac-ai-packs}"
|
|
python3 tools/ai/check_packs.py "$AI" \
|
|
|| { echo "✗ build the model parts (tools/ai/build_ai.py), or set AAC_AI=none to build without the sentence helper" >&2; exit 1; }
|
|
cp -cR "$AI" "$SITE/ai" 2>/dev/null || cp -R "$AI" "$SITE/ai"
|
|
touch "$SITE/ai/index.json" "$SITE/ai/LICENSES.md"
|
|
fi
|
|
AI_SNIP="$(grep -rl 'ensure_engine' "$SITE/pkg/snippets" || true)"
|
|
[ -n "$AI_SNIP" ] || { echo "✗ the sentence helper's snippet is not in pkg/snippets" >&2; exit 1; }
|
|
if grep -nE 'https?://' $AI_SNIP; then echo "✗ the sentence helper's snippet names another site" >&2; exit 1; fi
|
|
SNIP="$(grep -rl 'initialize_face_landmarker' "$SITE/pkg/snippets" || true)"
|
|
[ -n "$SNIP" ] || { echo "✗ eye-tracking snippet not found in pkg/snippets" >&2; exit 1; }
|
|
if grep -nE 'https?://' $SNIP; then echo "✗ eye-tracking snippet loads from a third party" >&2; exit 1; fi
|
|
|
|
echo "▸ bundling PWA assets and the service worker"
|
|
cp crates/aac-web/static/manifest.json "$SITE/manifest.json"
|
|
# Precache: the shell, the manifest, every file in pkg/ (including wasm-bindgen
|
|
# snippets such as the eye-tracking loader), the icons, and every symbol sprite
|
|
# the prerendered board draws (the default set and the sets it falls back to),
|
|
# so symbols show offline from the first visit, and their LICENSES.md. Other sprites are cached by the
|
|
# worker the first time they are drawn.
|
|
PRECACHE=("/" "/manifest.json")
|
|
while IFS= read -r f; do PRECACHE+=("/${f#"$SITE"/}"); done < <(find "$SITE/pkg" "$SITE/icons" -type f | LC_ALL=C sort)
|
|
DRAWN="$(grep -oE '/symbols/[a-z]+\.[0-9a-f]{12}\.svg' "$SITE/index.html" | LC_ALL=C sort -u || true)"
|
|
[ -n "$DRAWN" ] || { echo "✗ the prerendered board draws no symbols" >&2; exit 1; }
|
|
for u in $DRAWN; do
|
|
[ -f "$SITE$u" ] || { echo "✗ index.html draws $u, which the build does not ship" >&2; exit 1; }
|
|
PRECACHE+=("$u")
|
|
done
|
|
# Settings links the symbols' licences; the worker serves it offline too.
|
|
[ -f "$SITE/symbols/LICENSES.md" ] || { echo "✗ the build does not ship symbols/LICENSES.md" >&2; exit 1; }
|
|
PRECACHE+=("/symbols/LICENSES.md")
|
|
SYMBOL_FILES="[$(cd "$SITE/symbols" && printf '"%s",' *.svg | sed 's/,$//')]"
|
|
VERSION="$(for u in "${PRECACHE[@]}"; do
|
|
if [ "$u" = "/" ]; then f="$SITE/index.html"; else f="$SITE$u"; fi
|
|
printf '%s %s\n' "$u" "$(shasum -a 256 "$f" | cut -c1-64)"
|
|
done | shasum -a 256 | cut -c1-12)"
|
|
LIST="[$(printf '"%s",' "${PRECACHE[@]}" | sed 's/,$//')]"
|
|
# The worker keeps only the current vendor directory in its vendor cache.
|
|
sed -e "s#__VERSION__#$VERSION#" -e "s#__PRECACHE_URLS__#$LIST#" -e "s#__VENDOR_DIRS__#[\"$MP_DIR/\"]#" \
|
|
-e "s#__SYMBOL_FILES__#$SYMBOL_FILES#" \
|
|
crates/aac-web/static/sw.js > "$SITE/sw.js"
|
|
grep -q "__" "$SITE/sw.js" && grep -nE "__[A-Z_]+__" "$SITE/sw.js" && { echo "✗ sw.js has an unfilled placeholder" >&2; exit 1; }
|
|
|
|
echo "✓ self-contained dist ready at $SITE/ (service worker $VERSION, ${#PRECACHE[@]} files precached)"
|
|
ls -1 "$SITE" "$SITE/pkg"
|