Compare commits

...
2 Commits
Author SHA1 Message Date
Michael ShimelesandClaude Opus 5.5 6e53585b50 fix(web): fall back to shasum where sha256sum is missing
Older macOS releases ship shasum but not sha256sum, so build-web.sh
stopped with exit 127 before writing index.html. Both commands give the
same digest, so the folder names don't change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 08:35:34 -04:00
Michael ShimelesandClaude Opus 5.5 b5776f2abf fix(web): cache the app's wasm for good and serve /app/ without a redirect chain
scripts/build-web.sh now writes each build's JS and wasm to
static/app/immutable/<hash>/, named after a hash of those files, and
fills the paths into index.html. vercel.json caches /app/immutable/ for
a year and answers a missing file there with a 404 marked no-store.
index.html and the fonts keep Vercel's default of revalidating on every
visit, so a deploy can't leave a page with one build's JS and another
build's wasm.

/app/ used to take two 308s through the SvelteKit function to reach the
page (/app/ to ../app to /app/index.html). vercel.json now sends /app
to /app/ with one 308 and serves /app/ from index.html, so the /app
endpoint is gone. Relative URLs in the page still resolve under /app/.

Both workflows move off the Node 20 majors: actions/checkout@v7,
upload-artifact@v7, download-artifact@v8 and setup-node@v7 run on
Node 24.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 08:20:40 -04:00
8 changed files with 71 additions and 34 deletions

No files matched your search

+3 -3
View File
@@ -22,7 +22,7 @@ jobs:
arch: x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
@@ -67,7 +67,7 @@ jobs:
grep -q "smoke:" live.log
- name: Bundle .app
run: ./scripts/bundle-macos.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: always()
with:
name: macos-${{ matrix.arch }}
@@ -80,7 +80,7 @@ jobs:
linux:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
+5 -5
View File
@@ -27,7 +27,7 @@ jobs:
name: Build the browser app
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
# Pull requests build the branch head, like Vercel's Git previews did, not the merge commit.
ref: ${{ github.event.pull_request.head.sha || github.sha }}
@@ -51,7 +51,7 @@ jobs:
tar xzf "$RUNNER_TEMP/binaryen.tar.gz" -C "$RUNNER_TEMP"
echo "$RUNNER_TEMP/binaryen-$BINARYEN/bin" >> "$GITHUB_PATH"
- run: scripts/build-web.sh
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: web-app
path: site/static/app
@@ -84,14 +84,14 @@ jobs:
fi
done
exit $missing
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
name: web-app
path: site/static/app
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
+2 -2
View File
@@ -207,13 +207,13 @@ rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.129 --locked # must match Cargo.lock
brew install binaryen # wasm-opt
scripts/build-web.sh # writes site/static/app
cd site && npm run dev # then open http://localhost:5173/app
cd site && npm run dev # then open http://localhost:5173/app/index.html
```
`scripts/build-web.sh <dir>` writes somewhere else instead; any static file server works. Add
`?sample` to the URL to run offline on sample data, or `?fps` for the frame-time readout.
Every push to `master` builds it and deploys it with the site to
[terminal.rasmic.xyz/app](https://terminal.rasmic.xyz/app). Pull requests get a preview;
[terminal.rasmic.xyz/app](https://terminal.rasmic.xyz/app/). Pull requests get a preview;
[site/README.md](site/README.md#deploys) covers the workflow and the secrets it needs.
![The browser build in Chrome: a live BTCUSD chart from Binance beside a demo-account position](docs/img/web-live-crypto.png)
+7 -4
View File
@@ -10,8 +10,11 @@
// The WebGPU build when the browser has WebGPU, else the larger WebGL2 one (no charts).
// Start downloading now; the module below cancels this if there is no WebGPU adapter.
window.terminalLoad = { build: 'gpu' in navigator ? 'terminal' : 'terminal-webgl', abort: new AbortController() };
terminalLoad.wasm = fetch(`./${terminalLoad.build}_bg.wasm`, { signal: terminalLoad.abort.signal });
document.head.append(Object.assign(document.createElement('link'), { rel: 'modulepreload', href: `./${terminalLoad.build}.js` }));
// Each build's JS and wasm sit in a folder named after a hash of its files, so they can be
// cached for good; scripts/build-web.sh fills in these paths.
terminalLoad.paths = { terminal: './{terminal}', 'terminal-webgl': './{terminal-webgl}' };
terminalLoad.wasm = fetch(`${terminalLoad.paths[terminalLoad.build]}_bg.wasm`, { signal: terminalLoad.abort.signal });
document.head.append(Object.assign(document.createElement('link'), { rel: 'modulepreload', href: `${terminalLoad.paths[terminalLoad.build]}.js` }));
</script>
<style>
html,
@@ -56,11 +59,11 @@
load.wasm.catch(() => {}); // the abort below rejects it
load.abort.abort();
load.build = 'terminal-webgl';
load.wasm = fetch(`./${load.build}_bg.wasm`);
load.wasm = fetch(`${load.paths[load.build]}_bg.wasm`);
}
window.terminalBuild = load.build;
try {
const { default: init } = await import(`./${load.build}.js`);
const { default: init } = await import(`${load.paths[load.build]}.js`);
await init({ module_or_path: load.wasm }); // runs the app, which removes #status after its first frame
} catch (e) {
console.error(e);
+25 -8
View File
@@ -7,6 +7,11 @@
# WebGL2 fallback, which is larger and draws no charts). The page loads the second only when
# the browser has no WebGPU adapter.
#
# Each build's JS glue and wasm go in immutable/<hash>/, a folder named after a hash of its
# files, and index.html points at those folders. A new build gets new URLs, so the site can
# cache everything under immutable/ for good (site/vercel.json), and a page can't pair one
# build's JS with another build's wasm.
#
# Needs the wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown),
# wasm-bindgen-cli at the version pinned in Cargo.lock, and wasm-opt from binaryen.
set -euo pipefail
@@ -30,19 +35,32 @@ features=(--enable-bulk-memory --enable-bulk-memory-opt --enable-call-indirect-o
--enable-mutable-globals --enable-nontrapping-float-to-int --enable-reference-types --enable-sign-ext)
mkdir -p "$out"
rm -rf "$out"/terminal* "$out/fonts"
rm -rf "$out"/terminal* "$out/immutable" "$out/fonts"
# sed expressions that replace each {name} in index.html with that build's path
paths=()
# SHA-256 of stdin. Older macOS has shasum but no sha256sum.
sha256() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; }
# build <name> [cargo args...]
build() {
local name=$1
local name=$1 tmp="$out/immutable/.$1" hash
shift
cargo build --profile web --target "$target" -p terminal-rs "$@"
wasm-bindgen --target web --no-typescript --out-dir "$out" --out-name "$name" "$wasm"
wasm-opt -Os "${features[@]}" --strip-debug --strip-producers "$out/${name}_bg.wasm" -o "$out/${name}_bg.wasm"
wasm-bindgen --target web --no-typescript --out-dir "$tmp" --out-name "$name" "$wasm"
wasm-opt -Os "${features[@]}" --strip-debug --strip-producers "$tmp/${name}_bg.wasm" -o "$tmp/${name}_bg.wasm"
hash=$(cd "$tmp" && find . -type f | LC_ALL=C sort | xargs cat | sha256 | cut -c 1-16)
mv "$tmp" "$out/immutable/$hash"
paths+=(-e "s#{$name}#immutable/$hash/$name#g")
}
build terminal
build terminal-webgl --features webgl
cp app/web/index.html "$out/index.html"
sed "${paths[@]}" app/web/index.html >"$out/index.html"
if grep -n '{terminal[a-z-]*}' "$out/index.html" >&2; then
echo "index.html names a build that this script doesn't make" >&2
exit 1
fi
# egui's emoji fonts, which the page fetches after start-up (see app/src/web.rs), with their licenses.
fonts=$(cargo metadata --format-version 1 --locked --filter-platform "$target" | python3 -c '
@@ -54,9 +72,8 @@ cp "$fonts"/{NotoEmoji-Regular.ttf,OFL.txt,emoji-icon-font.ttf,emoji-icon-font-m
size() { wc -c <"$1" | tr -d ' '; }
echo "wrote $out"
for name in terminal terminal-webgl; do
f="$out/${name}_bg.wasm"
for f in "$out"/immutable/*/*_bg.wasm; do
gz=$(gzip -9c "$f" | wc -c | tr -d ' ')
br=$(command -v brotli >/dev/null && brotli -c -q 11 "$f" | wc -c | tr -d ' ' || echo "n/a")
echo "${name}_bg.wasm: $(size "$f") bytes raw, $gz gzip -9, $br brotli -q 11; ${name}.js: $(size "$out/$name.js") bytes"
echo "${f#"$out"/}: $(size "$f") bytes raw, $gz gzip -9, $br brotli -q 11; $(basename "${f%_bg.wasm}").js: $(size "${f%_bg.wasm}.js") bytes"
done
+12
View File
@@ -63,6 +63,18 @@ The workflow reads three repository secrets:
To redeploy without a new commit, run the workflow from the Actions tab. Run on `master`, it
deploys to production.
`vercel.json` also routes the browser app. `/app` redirects once to `/app/`, which serves
`static/app/index.html` without a redirect, so the page's relative URLs resolve under `/app/`.
`scripts/build-web.sh` puts each build's JS and wasm in `static/app/immutable/<hash>/`, named
after a hash of those files, and the browser caches everything under `/app/immutable/` for a
year. A new build gets new URLs, so a page never loads one build's JS with another build's wasm.
`index.html` and the fonts keep Vercel's default `max-age=0, must-revalidate`, so the browser
checks them on every visit. A request under `/app/immutable/` for a file that doesn't exist gets a
404 with `no-store`, so that year-long header never sticks to a 404. `vercel.json` uses `routes`
rather than `headers` and `rewrites` because only `routes` can add that rule after the filesystem
check, the same way the SvelteKit adapter guards `/_app/immutable/`. `npm run dev` doesn't apply
`vercel.json`; open `/app/index.html` there.
## Emails
`POST /download` adds each email to the `emails` sorted set in Upstash Redis, scored by when the
-11
View File
@@ -1,11 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
/**
* The browser build lives in `static/app` (written by `scripts/build-web.sh`, not committed). Static
* folders have no index page here, so send `/app` to the page itself.
*/
export const GET: RequestHandler = ({ url }) => {
// Keep the query (`?sample`, `?fps`).
redirect(308, `/app/index.html${url.search}`);
};
+17 -1
View File
@@ -2,5 +2,21 @@
"$schema": "https://openapi.vercel.sh/vercel.json",
"git": {
"deploymentEnabled": false
}
},
"routes": [
{ "src": "^/app$", "status": 308, "headers": { "Location": "/app/" } },
{
"src": "^/app/immutable/.+",
"headers": { "Cache-Control": "public, max-age=31536000, immutable" },
"continue": true
},
{ "handle": "filesystem" },
{ "src": "^/app/$", "dest": "/app/index.html", "check": true },
{
"src": "^/app/immutable/.+",
"status": 404,
"headers": { "Cache-Control": "no-store" },
"continue": false
}
]
}