#!/usr/bin/env bash # ============================================================================ # vendor.sh — put the sentence helper's runtime (Google's LiteRT-LM for the # web) on the app's own site. # # third_party/litert-lm/vendor.sh [cache-dir] # # Writes /vendor/litert-lm--/ (and removes any # other vendor/litert-lm-* there): # ai-worker.js this directory's worker.js, bundled by esbuild # with @litert-lm/core and @litertjs/wasm-utils # into one classic worker script # litertlm_wasm_*_internal.js and .wasm # the runtime's four WebAssembly builds, from the # npm package, unchanged # LICENSE.txt, NOTICE.txt this directory's licence and notice # # The app never imports these URLs directly. When someone downloads the # sentence helper, src/ai_helper.rs stores the worker and the one WebAssembly # build their browser runs in the model store (SHA-256 checked, kept across # app updates), and src/bindings/ai_helper.js runs them from there, in a # worker, offline. # # The version and revision come from LITERT_LM_VERSION and # LITERT_LM_VENDOR_REV in crates/aac-web/src/ai_helper.rs, and each runtime # file's size and SHA-256 from RUNTIME_FILES there. This script fails loudly # on any drift: another version asked for than pinned here, an npm tarball # that is not the pinned one, a vendored file that differs from the Rust pin, # or a directory whose bytes differ from VENDOR_DIGEST. Changed bytes need a # new revision, because browsers and the model store treat the directory's # URLs as immutable. # # Idempotent; downloads are cached in cache-dir (default ~/.cache/aac-rs). # Needs node (npm, npx) for the bundle. # ============================================================================ set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" AAC="$(cd "$HERE/../.." && pwd)" SITE="${1:?usage: vendor.sh [cache-dir]}" CACHE="${2:-${XDG_CACHE_HOME:-$HOME/.cache}/aac-rs}" AH="$AAC/crates/aac-web/src/ai_helper.rs" LM_VERSION="$(sed -n 's/^pub const LITERT_LM_VERSION: &str = "\([^"]*\)";$/\1/p' "$AH")" LM_REV="$(sed -n 's/^pub const LITERT_LM_VENDOR_REV: &str = "\([^"]*\)";$/\1/p' "$AH")" [ -n "$LM_VERSION" ] || { echo "✗ LITERT_LM_VERSION not found in $AH" >&2; exit 1; } [ -n "$LM_REV" ] || { echo "✗ LITERT_LM_VENDOR_REV not found in $AH" >&2; exit 1; } DIR="litert-lm-$LM_VERSION-$LM_REV" # ─── Pins ──────────────────────────────────────────────────────────────────── # The npm releases, by their registry integrity (npm view @ # dist.integrity, read 2026-10-01). @litert-lm/core asks for # @litertjs/wasm-utils ^2.0.0; 2.5.3 is the latest. PINNED_VERSION="0.17.1" NPM_INTEGRITY="sha512-eUxw6ggqZrvNA8Uk/w0TwglRWryn/clrjqHYyfxQWRl0uz5Txmte+qss0u3Dt+X6W/io4G8ADk97LmjMxVRHUg==" WU_VERSION="2.5.3" WU_INTEGRITY="sha512-4fJiw6tBQnIs+0jH/OQ16nYOiSl8/+zZnn5h2ul8aTKHi05sVqZW580Tmk78pC2kT39A9OYBVdFawteZ/sI1kQ==" # The bundler. Its output for the same input is the same on every platform; # the bundle's bytes are pinned in ai_helper.rs all the same. ESBUILD_VERSION="0.28.2" # LiteRT-LM's LICENSE at tag v0.17.1 (commit 5e58e9a0aef7abf7091207a8b1d1063a1c800f08). LICENSE_SHA256="c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" # What vendor/$VENDOR_PINNED holds, as a sha256 over its sorted file list and # each file's sha256. Re-pin only together with a new LITERT_LM_VENDOR_REV. VENDOR_PINNED="litert-lm-0.17.1-aac1" VENDOR_DIGEST="a1b35e0e0f11c25b77e2d7cc9c4b7a3c580026771f0a24530ce3ff90dcebcdb6" # The runtime's WebAssembly builds, as the npm package names them. WASM_BUILDS=(litertlm_wasm_internal litertlm_wasm_asyncify_internal litertlm_wasm_compat_internal litertlm_wasm_compat_asyncify_internal) [ "$LM_VERSION" = "$PINNED_VERSION" ] || { echo "✗ ai_helper.rs asks for LiteRT-LM $LM_VERSION, but vendor.sh is pinned to $PINNED_VERSION —" >&2 echo " check the new release (licence, no network use, the model it runs), then re-pin PINNED_VERSION and NPM_INTEGRITY" >&2 exit 1 } [ "$DIR" = "$VENDOR_PINNED" ] || { echo "✗ ai_helper.rs asks for $DIR, but vendor.sh is pinned to $VENDOR_PINNED — vendor it, check it, and re-pin VENDOR_PINNED and VENDOR_DIGEST" >&2 exit 1 } sha256() { shasum -a 256 "$1" | cut -c1-64; } fsize() { wc -c < "$1" | tr -d ' '; } echo "▸ vendoring LiteRT-LM $LM_VERSION ($DIR)" mkdir -p "$CACHE" # Tell (but do not fail) when npm has a newer release to review. LATEST="$(npm view @litert-lm/core version 2>/dev/null || true)" if [ -n "$LATEST" ] && [ "$LATEST" != "$LM_VERSION" ]; then echo " note: npm's latest @litert-lm/core is $LATEST (pinned: $LM_VERSION)" fi # An npm package, fetched and checked against its pinned integrity beside the # cache entry, then moved in whole, so an interrupted run never leaves half a # package that looks complete. npm_package npm_package() { local name="$1" version="$2" integrity="$3" dir="$4" if [ ! -f "$dir/package/package.json" ]; then local tmp tgz got tmp="$(mktemp -d "$CACHE/npm.XXXXXX")" (cd "$tmp" && npm pack --silent "$name@$version" >/dev/null) tgz="$(ls "$tmp"/*.tgz)" got="sha512-$(openssl dgst -sha512 -binary "$tgz" | base64 | tr -d '\n')" [ "$got" = "$integrity" ] || { rm -rf "$tmp" echo "✗ $name@$version from npm is not the pinned tarball (got $got)" >&2 exit 1 } (cd "$tmp" && tar xzf "$tgz") rm -rf "$dir" mv "$tmp" "$dir" fi grep -q "\"version\": \"$version\"" "$dir/package/package.json" || { echo "✗ cached $name is not $version" >&2; exit 1; } grep -q '"license": "Apache-2.0"' "$dir/package/package.json" || { echo "✗ $name $version no longer declares Apache-2.0 — review it" >&2; exit 1; } } LM_CACHE="$CACHE/litert-lm-core-$LM_VERSION" WU_CACHE="$CACHE/litertjs-wasm-utils-$WU_VERSION" npm_package "@litert-lm/core" "$LM_VERSION" "$NPM_INTEGRITY" "$LM_CACHE" npm_package "@litertjs/wasm-utils" "$WU_VERSION" "$WU_INTEGRITY" "$WU_CACHE" grep -q '"@litertjs/wasm-utils": "^2' "$LM_CACHE/package/package.json" \ || { echo "✗ @litert-lm/core $LM_VERSION no longer asks for @litertjs/wasm-utils 2.x — review it" >&2; exit 1; } [ "$(sha256 "$HERE/LICENSE.txt")" = "$LICENSE_SHA256" ] || { echo "✗ $HERE/LICENSE.txt is not LiteRT-LM's LICENSE at v0.17.1" >&2; exit 1; } # The worker bundle: worker.js and the two packages, laid out as npm would, # bundled into one classic script (the runtime loads its glue with # importScripts, which module workers lack). WORK="$(mktemp -d "$CACHE/litert-lm-bundle.XXXXXX")" trap 'rm -rf "$WORK"' EXIT mkdir -p "$WORK/node_modules/@litert-lm" "$WORK/node_modules/@litertjs" cp -R "$LM_CACHE/package" "$WORK/node_modules/@litert-lm/core" cp -R "$WU_CACHE/package" "$WORK/node_modules/@litertjs/wasm-utils" cp "$HERE/worker.js" "$WORK/worker.js" (cd "$WORK" && npx --yes "esbuild@$ESBUILD_VERSION" worker.js --bundle --format=iife --platform=browser \ --target=es2022 --legal-comments=none --charset=utf8 --log-level=warning --outfile=ai-worker.js) # The runtime's default WebAssembly location is a public CDN; the app always # hands it the WebAssembly, so empty it: nothing can then reach another site. CDN="https://cdn.jsdelivr.net/npm/@litert-lm/core@$LM_VERSION/wasm" grep -qF "\"$CDN\"" "$WORK/ai-worker.js" || { echo "✗ the runtime's default WebAssembly URL has changed — review the bundle" >&2; exit 1; } sed "s#\"$CDN\"#\"\"#" "$WORK/ai-worker.js" > "$WORK/ai-worker.js.tmp" && mv "$WORK/ai-worker.js.tmp" "$WORK/ai-worker.js" rm -rf "$SITE"/vendor/litert-lm-* LM="$SITE/vendor/$DIR" mkdir -p "$LM" cp "$WORK/ai-worker.js" "$HERE/LICENSE.txt" "$HERE/NOTICE.txt" "$LM/" for b in "${WASM_BUILDS[@]}"; do cp "$LM_CACHE/package/wasm/$b.js" "$LM_CACHE/package/wasm/$b.wasm" "$LM/" done chmod 644 "$LM"/* # The runtime must never reach another site: no URL in the worker at all, and # none in a string in the glue scripts (their comments cite Emscripten's docs). if grep -nE 'https?://' "$LM/ai-worker.js"; then echo "✗ the sentence helper's worker names another site — review it" >&2; exit 1 fi for b in "${WASM_BUILDS[@]}"; do if grep -nE "[\"'\`]https?://" "$LM/$b.js"; then echo "✗ $b.js loads from another site — review it" >&2; exit 1 fi done # Each runtime file must be exactly what ai_helper.rs pins (RUNTIME_FILES): # the app downloads it by that size and SHA-256. PINS="$(sed -n 's/^ *RuntimeFile { name: "\([^"]*\)", bytes: \([0-9_]*\), sha256: "\([0-9a-f]*\)" },$/\1 \2 \3/p' "$AH" | awk '{ gsub(/_/, "", $2); print }')" [ "$(printf '%s\n' "$PINS" | grep -c .)" = 9 ] || { echo "✗ RUNTIME_FILES in $AH should pin 9 files" >&2; exit 1; } bad=0 while read -r name bytes want; do f="$LM/$name" [ -s "$f" ] || { echo "✗ vendored runtime is missing $name" >&2; exit 1; } got_bytes="$(fsize "$f")"; got="$(sha256 "$f")" if [ "$got_bytes" != "$bytes" ] || [ "$got" != "$want" ]; then echo "✗ vendor/$DIR/$name is $got_bytes bytes, sha256 $got;" >&2 echo " ai_helper.rs RUNTIME_FILES pins $bytes bytes, sha256 $want." >&2 bad=1 fi done <<< "$PINS" [ "$bad" = 0 ] || { echo " Changed bytes need a new LITERT_LM_VENDOR_REV (a new URL), then new pins there and here." >&2; exit 1; } DIGEST="$(cd "$LM" && find . -type f | LC_ALL=C sort | while IFS= read -r f; do printf '%s %s\n' "$f" "$(sha256 "$f")"; done | shasum -a 256 | cut -c1-64)" [ "$DIGEST" = "$VENDOR_DIGEST" ] || { echo "✗ vendor/$DIR differs from what was pinned (got $DIGEST)." >&2 echo " Its URLs are cached as immutable: bump LITERT_LM_VENDOR_REV in ai_helper.rs," >&2 echo " then set VENDOR_PINNED and VENDOR_DIGEST in $HERE/vendor.sh." >&2 exit 1 } echo "✓ vendor/$DIR ($(du -sh "$LM" | cut -f1))"