authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 00:28:37-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 05:19:31-07:00
logb60e999838e28bdf93ac01346599a386226ddf7d
treeb2265a21488c76b86bbb298469b481e3f41233f8
parent8587d48a5c4256dcc935abf9ad5a8a23329cea5e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the web build deploys to snowbound.paperclover.net, each build's module and JavaScript in a folder named by their contents

release_web.py --deploy rsyncs the site to the VPS, where tools/web/serve.py (pm2's snowbound-web) has index.html checked on every load and keeps b/HASH/ for good; the last three builds stay for pages still running them. A page whose cached module and JavaScript don't link reloads once afresh. The copyparty share's web/ folder is no longer published. Assisted-by: claude-opus-5.5

4 files changed, 85 insertions(+), 36 deletions(-)

arc/platforms.md+1-1
...@@ -167,7 +167,7 @@ keyboard, the toolbar and the macOS menu bar all run commands from it....@@ -167,7 +167,7 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
167 module, and `web/index.html` and `web/glue.js` are the page around it. `glue.js` brings the167 module, and `web/index.html` and `web/glue.js` are the page around it. `glue.js` brings the
168 canvas's pointer, wheel and touch, and a hidden text area's keys, composition and paste, as168 canvas's pointer, wheel and touch, and a hidden text area's keys, composition and paste, as
169 the `ui::Event`s winit would; `web.rs` stands in for winit's window and event loop and runs169 the `ui::Event`s winit would; `web.rs` stands in for winit's window and event loop and runs
170 `State` a turn per animation frame. `tools/release_web.py` builds and publishes it.170 `State` a turn per animation frame. `tools/release_web.py` builds it and deploys it to https://snowbound.paperclover.net.
171- The page has one thread. The notebook's section thread, sync worker and background run as171- The page has one thread. The notebook's section thread, sync worker and background run as
172 tasks on its event loop (`notebook::task`), and work the desktop gives a thread runs once172 tasks on its event loop (`notebook::task`), and work the desktop gives a thread runs once
173 the frame is done (`spawn`).173 the frame is done (`spawn`).
tools/TESTING.md+1-1
...@@ -19,7 +19,7 @@ another, and the exit status is the result....@@ -19,7 +19,7 @@ another, and the exit status is the result.
19| `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) |19| `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) |
20| `windows-aarch64`, `linux-*` | Clippy `-D warnings` on what ships (libraries and binaries but `mobile`), then the `snowbound` build; Linux through `platform/linux/cargo.sh`, which links with zig against glibc 2.17 |20| `windows-aarch64`, `linux-*` | Clippy `-D warnings` on what ships (libraries and binaries but `mobile`), then the `snowbound` build; Linux through `platform/linux/cargo.sh`, which links with zig against glibc 2.17 |
21| `ios` | `xcodebuild` of the simulator app, unsigned |21| `ios` | `xcodebuild` of the simulator app, unsigned |
22| `web` | Clippy `-D warnings` on `snowbound` for `wasm32-unknown-unknown`, SQLite built by nixpkgs' clang; `release_web.py` links, optimizes and publishes the static folder |22| `web` | Clippy `-D warnings` on `snowbound` for `wasm32-unknown-unknown`, SQLite built by nixpkgs' clang; `release_web.py` links, optimizes and deploys the static folder |
23| `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` |23| `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` |
2424
25```sh25```sh
tools/release_web.py+52-34
...@@ -1,9 +1,14 @@...@@ -1,9 +1,14 @@
1#!/usr/bin/env python31#!/usr/bin/env python3
2"""Builds Snowbound for the web (crates/snowbound for wasm32, with crates/snowbound/web) as a2"""Builds Snowbound for the web (crates/snowbound for wasm32, with crates/snowbound/web) as a
3static folder and publishes it to the share's web/ folder.3static folder and deploys it to the VPS, which serves it at https://snowbound.paperclover.net.
44
5 python3 tools/release_web.py --out /tmp/snowbound-web/dist # build only5 python3 tools/release_web.py --out /tmp/snowbound-web/dist # build only
6 python3 tools/release_web.py --publish # build, then replace web/6 python3 tools/release_web.py --deploy # build, then replace the site
7
8The module and its JavaScript go in a folder named by their contents, b/HASH/, which
9index.html names, so a page never pairs one build's module with another's JavaScript; the
10server (tools/web/serve.py, which pm2's `snowbound-web` runs as `serve.py 23593 site` in the
11VPS's snowbound-web/) has index.html checked on every load and keeps a build's folder for good.
7"""12"""
8import argparse13import argparse
9import gzip14import gzip
...@@ -26,9 +31,13 @@ WEB = ROOT / 'crates/snowbound/web'...@@ -26,9 +31,13 @@ WEB = ROOT / 'crates/snowbound/web'
26FONTS = ROOT / 'crates/canvas/assets/fonts'31FONTS = ROOT / 'crates/canvas/assets/fonts'
27# Where the loading shell's icons are looked up by name, in order.32# Where the loading shell's icons are looked up by name, in order.
28ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets']33ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets']
29PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web')34# The VPS's folder: serve.py, and the site it serves on 127.0.0.1:23593 as pm2's
30# copyparty lists the folder itself, so the app is at its index.html.35# `snowbound-web`, behind https://snowbound.paperclover.net.
31URL = 'https://file.paperclover.net/shr/snowbound/web/index.html'36HOST = 'clo@paperclover.net'
37DEPLOYED = 'snowbound-web'
38URL = 'https://snowbound.paperclover.net'
39# Builds the server keeps beside the newest, for pages still running one.
40KEPT = 3
32EMOJI_URL = 'https://github.com/googlefonts/noto-emoji/raw/v2.051/fonts/Noto-COLRv1.ttf'41EMOJI_URL = 'https://github.com/googlefonts/noto-emoji/raw/v2.051/fonts/Noto-COLRv1.ttf'
33EMOJI_SHA256 = '0ae57fe58645638523ba35f388d93739d292539a9acb84df5700c81b1e1a28d2'42EMOJI_SHA256 = '0ae57fe58645638523ba35f388d93739d292539a9acb84df5700c81b1e1a28d2'
34# As `EMOJI` in src/web.rs names it.43# As `EMOJI` in src/web.rs names it.
...@@ -91,25 +100,32 @@ def build(out):...@@ -91,25 +100,32 @@ def build(out):
91 shutil.rmtree(out)100 shutil.rmtree(out)
92 out.mkdir(parents=True)101 out.mkdir(parents=True)
93 module = TARGET / 'wasm32-unknown-unknown/release/snowbound.wasm'102 module = TARGET / 'wasm32-unknown-unknown/release/snowbound.wasm'
103 code = out / 'b/next'
94 run([tool('wasm-bindgen'), '--target', 'web', '--no-typescript', '--out-name', 'snowbound_web',104 run([tool('wasm-bindgen'), '--target', 'web', '--no-typescript', '--out-name', 'snowbound_web',
95 '--out-dir', out, module])105 '--out-dir', code, module])
96 bound = out / 'snowbound_web_bg.wasm'106 bound = code / 'snowbound_web_bg.wasm'
97 run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound])107 run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound])
108 digest = hashlib.sha256()
109 for path in sorted(code.rglob('*')):
110 if path.is_file():
111 digest.update(str(path.relative_to(code)).encode() + b'\0' + path.read_bytes())
112 code = code.rename(out / 'b' / digest.hexdigest()[:16])
98 dictionaries(out / 'dictionaries')113 dictionaries(out / 'dictionaries')
99 (out / 'fonts').mkdir()114 (out / 'fonts').mkdir()
100 for font in sorted(FONTS.glob('*')):115 for font in sorted(FONTS.glob('*')):
101 if font.suffix in ('.ttf', '.txt'):116 if font.suffix in ('.ttf', '.txt'):
102 shutil.copy(font, out / 'fonts')117 shutil.copy(font, out / 'fonts')
103 fallbacks(out / 'fonts')118 fallbacks(out / 'fonts')
104 page(out)119 page(out, code.relative_to(out).as_posix())
105 for path in sorted(out.rglob('*')):120 for path in sorted(out.rglob('*')):
106 if path.is_file():121 if path.is_file():
107 print(f'{path.stat().st_size:>12,} {path.relative_to(out)}')122 print(f'{path.stat().st_size:>12,} {path.relative_to(out)}')
108123
109124
110def page(out):125def page(out, code):
111 """Writes index.html with its loading shell's icons inlined, and the sizes of the files126 """Writes index.html with its loading shell's icons inlined, the folder `code` it takes
112 it fetches before the app starts, for its progress bar."""127 the module and its JavaScript from, and the sizes of the files it fetches before the app
128 starts, for its progress bar."""
113 count = itertools.count()129 count = itertools.count()
114130
115 def inline(match):131 def inline(match):
...@@ -121,12 +137,15 @@ def page(out):...@@ -121,12 +137,15 @@ def page(out):
121 svg = re.sub(r'url\(#([^)]+)\)', rf'url(#{prefix}\1)', svg)137 svg = re.sub(r'url\(#([^)]+)\)', rf'url(#{prefix}\1)', svg)
122 return f'<i{match[1]}>{svg.strip()}</i>'138 return f'<i{match[1]}>{svg.strip()}</i>'
123 html = re.sub(r'<i([^>]*) icon="([^"]+)"></i>', inline, (WEB / 'index.html').read_text())139 html = re.sub(r'<i([^>]*) icon="([^"]+)"></i>', inline, (WEB / 'index.html').read_text())
124 sizes = {str(path.relative_to(out)): path.stat().st_size140 sizes = {path.relative_to(out).as_posix(): path.stat().st_size
125 for path in [out / 'snowbound_web_bg.wasm', *sorted((out / 'fonts').glob('*.ttf'))]}141 for path in [out / code / 'snowbound_web_bg.wasm', *sorted((out / 'fonts').glob('*.ttf'))]}
126 marker = 'const SIZES = {};'142 for marker, written in [('const SIZES = {};', f'const SIZES = {json.dumps(sizes)};'),
127 if marker not in html:143 ('"./snowbound_web.js"', f'"./{code}/snowbound_web.js"'),
128 sys.exit(f'index.html lacks `{marker}`')144 ('const MODULE = "snowbound_web_bg.wasm";', f'const MODULE = "{code}/snowbound_web_bg.wasm";')]:
129 (out / 'index.html').write_text(html.replace(marker, f'const SIZES = {json.dumps(sizes)};'))145 if marker not in html:
146 sys.exit(f'index.html lacks `{marker}`')
147 html = html.replace(marker, written)
148 (out / 'index.html').write_text(html)
130149
131150
132def dictionaries(folder):151def dictionaries(folder):
...@@ -187,32 +206,31 @@ def fallbacks(fonts):...@@ -187,32 +206,31 @@ def fallbacks(fonts):
187 (fonts / 'Noto-OFL.txt').write_text('Noto fonts: SIL Open Font License 1.1, https://openfontlicense.org\n')206 (fonts / 'Noto-OFL.txt').write_text('Noto fonts: SIL Open Font License 1.1, https://openfontlicense.org\n')
188207
189208
190def publish(built):209def deploy(built):
191 """Replaces the share's web/ folder with `built`, leaving every other folder alone."""210 """Makes the VPS's site `built`, each file taking its place once all have arrived, and
192 if not PUBLISHED.parent.is_dir():211 leaves the last builds' folders for pages still running them."""
193 sys.exit(f'{PUBLISHED.parent} is not mounted')212 sync = ['rsync', '--recursive', '--links', '--times', '--compress', '--itemize-changes']
194 staging = PUBLISHED.with_name('.web-staging')213 server = subprocess.run([*sync, ROOT / 'tools/web/serve.py', f'{HOST}:{DEPLOYED}/'],
195 if staging.exists():214 check=True, capture_output=True, text=True).stdout
196 shutil.rmtree(staging)215 run([*sync, '--delete', '--delay-updates', '--filter=P b/*', f'{built}/', f'{HOST}:{DEPLOYED}/site/'])
197 shutil.copytree(built, staging)216 run(['ssh', HOST, f'cd {DEPLOYED}/site/b && ls -t | tail -n +{KEPT + 2} | xargs -r rm -rf --'])
198 if PUBLISHED.exists():217 if server.strip():
199 shutil.rmtree(PUBLISHED)218 run(['ssh', HOST, 'pm2 restart snowbound-web'])
200 staging.rename(PUBLISHED)219 print(f'Deployed {URL}')
201 print(f'Published {URL}')
202220
203221
204def main():222def main():
205 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)223 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
206 parser.add_argument('--out', type=Path, help='Build into this folder (default: a temporary one)')224 parser.add_argument('--out', type=Path, help='Build into this folder (default: a temporary one)')
207 parser.add_argument('--publish', action='store_true', help=f'Copy the build to {PUBLISHED}')225 parser.add_argument('--deploy', action='store_true', help=f'Copy the build to {DEPLOYED}')
208 args = parser.parse_args()226 args = parser.parse_args()
209 if not args.out and not args.publish:227 if not args.out and not args.deploy:
210 parser.error('give --out, --publish or both')228 parser.error('give --out, --deploy or both')
211 with tempfile.TemporaryDirectory() as scratch:229 with tempfile.TemporaryDirectory() as scratch:
212 out = args.out or Path(scratch) / 'web'230 out = args.out or Path(scratch) / 'web'
213 build(out)231 build(out)
214 if args.publish:232 if args.deploy:
215 publish(out)233 deploy(out)
216234
217235
218if __name__ == '__main__':236if __name__ == '__main__':
tools/web/serve.py created+31
...@@ -0,0 +1,31 @@
1#!/usr/bin/env python3
2"""Serves the web build on 127.0.0.1, behind the VPS's Caddy and Cloudflare: index.html is
3checked every load, a build's folder (b/HASH/) is kept for good, and the fonts and
4dictionaries for a day.
5
6 python3 serve.py PORT FOLDER
7"""
8import functools
9import http.server
10import sys
11
12
13class Handler(http.server.SimpleHTTPRequestHandler):
14 extensions_map = {**http.server.SimpleHTTPRequestHandler.extensions_map, '.wasm': 'application/wasm'}
15
16 def end_headers(self):
17 path = self.path.split('?')[0]
18 if path.startswith('/b/'):
19 cache = 'public, max-age=31536000, immutable'
20 elif path in ('/', '/index.html'):
21 cache = 'no-cache'
22 else:
23 cache = 'public, max-age=86400'
24 self.send_header('Cache-Control', cache)
25 super().end_headers()
26
27
28if __name__ == '__main__':
29 port, folder = int(sys.argv[1]), sys.argv[2]
30 handler = functools.partial(Handler, directory=folder)
31 http.server.ThreadingHTTPServer(('127.0.0.1', port), handler).serve_forever()