Compare 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
Michael ShimelesandClaude Opus 5.5 bf1dd94fd5 ci(web): deploy the site with the browser build from GitHub Actions (#24)
Vercel's build doesn't run Rust, so a deploy from Vercel's Git integration
has no /app. The new deploy-site workflow runs scripts/build-web.sh, then
vercel build and vercel deploy --prebuilt. Pushes to master deploy to
production, and pull requests from this repository get a preview.
site/vercel.json turns off Vercel's Git deployments so each commit deploys
once.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 07:47:03 -04:00
Michael ShimelesandClaude Opus 5.5 728179ae66 feat(web): run the terminal in the browser with WebAssembly and WebGPU (#23)
* feat(web): build the terminal for the browser with WebAssembly

Compile the app for wasm32-unknown-unknown and run it with eframe's web
runner on WebGPU, with a separate WebGL2 build for browsers without it.
Desktop-only pieces sit behind cfg(target_arch = "wasm32") seams: a
tokio/browser task shim, a websocket wrapper, localStorage in place of
files, and stand-ins for SQLite, Congress ingest and SnapTrade.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* perf(web): WebGPU-only main build, emoji fonts after first frame

The default browser build drops wgpu's WebGL2 path (naga, wgpu-core,
wgpu-hal), and the page loads a second build with it only when there is
no WebGPU adapter. egui's emoji fonts now load after the first frame
instead of shipping inside the .wasm. Together that takes the WebGPU
bundle from 2.64 MB to 1.69 MB brotli.

Also: label the browser's mixed data source as "Crypto live", tell the
Congress panes the data needs the desktop app, autosave every 2 s in the
browser, and redirect /app to the build on the site.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(readme): state browser timings as ranges across runs

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(readme): use the Retina window run for browser timings

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: browser build screenshots

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(readme): final-build throttled timing

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(readme): rewrap browser timings

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(web): real downloads for exports, one wasm download, keep /app flags

- Layout and theme exports download a file in the browser instead of
  writing a hidden localStorage key. Imports and "open folder" say they
  need the desktop app for now.
- The page starts the WebGPU wasm with an abortable fetch and cancels it
  when there is no WebGPU adapter, so only the WebGL2 build downloads.
- /app keeps its query string when it redirects to /app/index.html.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-29 07:47:02 -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
}
]
}