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.
167167 module, and `web/index.html` and `web/glue.js` are the page around it. `glue.js` brings the
168168 canvas's pointer, wheel and touch, and a hidden text area's keys, composition and paste, as
169169 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.
171171- The page has one thread. The notebook's section thread, sync worker and background run as
172172 tasks on its event loop (`notebook::task`), and work the desktop gives a thread runs once
173173 the frame is done (`spawn`).
tools/TESTING.md+1-1
......@@ -19,7 +19,7 @@ another, and the exit status is the result.
1919| `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) |
2020| `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 |
2121| `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 |
2323| `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` |
2424
2525```sh
tools/release_web.py+52-34
......@@ -1,9 +1,14 @@
11#!/usr/bin/env python3
22"""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
55 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.
712"""
813import argparse
914import gzip
......@@ -26,9 +31,13 @@ WEB = ROOT / 'crates/snowbound/web'
2631FONTS = ROOT / 'crates/canvas/assets/fonts'
2732# Where the loading shell's icons are looked up by name, in order.
2833ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets']
29PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web')
30# copyparty lists the folder itself, so the app is at its index.html.
31URL = 'https://file.paperclover.net/shr/snowbound/web/index.html'
34# The VPS's folder: serve.py, and the site it serves on 127.0.0.1:23593 as pm2's
35# `snowbound-web`, behind https://snowbound.paperclover.net.
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
3241EMOJI_URL = 'https://github.com/googlefonts/noto-emoji/raw/v2.051/fonts/Noto-COLRv1.ttf'
3342EMOJI_SHA256 = '0ae57fe58645638523ba35f388d93739d292539a9acb84df5700c81b1e1a28d2'
3443# As `EMOJI` in src/web.rs names it.
......@@ -91,25 +100,32 @@ def build(out):
91100 shutil.rmtree(out)
92101 out.mkdir(parents=True)
93102 module = TARGET / 'wasm32-unknown-unknown/release/snowbound.wasm'
103 code = out / 'b/next'
94104 run([tool('wasm-bindgen'), '--target', 'web', '--no-typescript', '--out-name', 'snowbound_web',
95 '--out-dir', out, module])
96 bound = out / 'snowbound_web_bg.wasm'
105 '--out-dir', code, module])
106 bound = code / 'snowbound_web_bg.wasm'
97107 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])
98113 dictionaries(out / 'dictionaries')
99114 (out / 'fonts').mkdir()
100115 for font in sorted(FONTS.glob('*')):
101116 if font.suffix in ('.ttf', '.txt'):
102117 shutil.copy(font, out / 'fonts')
103118 fallbacks(out / 'fonts')
104 page(out)
119 page(out, code.relative_to(out).as_posix())
105120 for path in sorted(out.rglob('*')):
106121 if path.is_file():
107122 print(f'{path.stat().st_size:>12,} {path.relative_to(out)}')
108123
109124
110def page(out):
111 """Writes index.html with its loading shell's icons inlined, and the sizes of the files
112 it fetches before the app starts, for its progress bar."""
125def page(out, code):
126 """Writes index.html with its loading shell's icons inlined, the folder `code` it takes
127 the module and its JavaScript from, and the sizes of the files it fetches before the app
128 starts, for its progress bar."""
113129 count = itertools.count()
114130
115131 def inline(match):
......@@ -121,12 +137,15 @@ def page(out):
121137 svg = re.sub(r'url\(#([^)]+)\)', rf'url(#{prefix}\1)', svg)
122138 return f'<i{match[1]}>{svg.strip()}</i>'
123139 html = re.sub(r'<i([^>]*) icon="([^"]+)"></i>', inline, (WEB / 'index.html').read_text())
124 sizes = {str(path.relative_to(out)): path.stat().st_size
125 for path in [out / 'snowbound_web_bg.wasm', *sorted((out / 'fonts').glob('*.ttf'))]}
126 marker = 'const SIZES = {};'
127 if marker not in html:
128 sys.exit(f'index.html lacks `{marker}`')
129 (out / 'index.html').write_text(html.replace(marker, f'const SIZES = {json.dumps(sizes)};'))
140 sizes = {path.relative_to(out).as_posix(): path.stat().st_size
141 for path in [out / code / 'snowbound_web_bg.wasm', *sorted((out / 'fonts').glob('*.ttf'))]}
142 for marker, written in [('const SIZES = {};', f'const SIZES = {json.dumps(sizes)};'),
143 ('"./snowbound_web.js"', f'"./{code}/snowbound_web.js"'),
144 ('const MODULE = "snowbound_web_bg.wasm";', f'const MODULE = "{code}/snowbound_web_bg.wasm";')]:
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
132151def dictionaries(folder):
......@@ -187,32 +206,31 @@ def fallbacks(fonts):
187206 (fonts / 'Noto-OFL.txt').write_text('Noto fonts: SIL Open Font License 1.1, https://openfontlicense.org\n')
188207
189208
190def publish(built):
191 """Replaces the share's web/ folder with `built`, leaving every other folder alone."""
192 if not PUBLISHED.parent.is_dir():
193 sys.exit(f'{PUBLISHED.parent} is not mounted')
194 staging = PUBLISHED.with_name('.web-staging')
195 if staging.exists():
196 shutil.rmtree(staging)
197 shutil.copytree(built, staging)
198 if PUBLISHED.exists():
199 shutil.rmtree(PUBLISHED)
200 staging.rename(PUBLISHED)
201 print(f'Published {URL}')
209def deploy(built):
210 """Makes the VPS's site `built`, each file taking its place once all have arrived, and
211 leaves the last builds' folders for pages still running them."""
212 sync = ['rsync', '--recursive', '--links', '--times', '--compress', '--itemize-changes']
213 server = subprocess.run([*sync, ROOT / 'tools/web/serve.py', f'{HOST}:{DEPLOYED}/'],
214 check=True, capture_output=True, text=True).stdout
215 run([*sync, '--delete', '--delay-updates', '--filter=P b/*', f'{built}/', f'{HOST}:{DEPLOYED}/site/'])
216 run(['ssh', HOST, f'cd {DEPLOYED}/site/b && ls -t | tail -n +{KEPT + 2} | xargs -r rm -rf --'])
217 if server.strip():
218 run(['ssh', HOST, 'pm2 restart snowbound-web'])
219 print(f'Deployed {URL}')
202220
203221
204222def main():
205223 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
206224 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}')
208226 args = parser.parse_args()
209 if not args.out and not args.publish:
210 parser.error('give --out, --publish or both')
227 if not args.out and not args.deploy:
228 parser.error('give --out, --deploy or both')
211229 with tempfile.TemporaryDirectory() as scratch:
212230 out = args.out or Path(scratch) / 'web'
213231 build(out)
214 if args.publish:
215 publish(out)
232 if args.deploy:
233 deploy(out)
216234
217235
218236if __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()