diff --git a/arc/platforms.md b/arc/platforms.md index bebe517aebece8a40eef93be5c6b9d00400c4573..375cc856e1db66079f754f248ee8f62cd7096598 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -167,7 +167,7 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. module, and `web/index.html` and `web/glue.js` are the page around it. `glue.js` brings the canvas's pointer, wheel and touch, and a hidden text area's keys, composition and paste, as the `ui::Event`s winit would; `web.rs` stands in for winit's window and event loop and runs - `State` a turn per animation frame. `tools/release_web.py` builds and publishes it. + `State` a turn per animation frame. `tools/release_web.py` builds it and deploys it to https://snowbound.paperclover.net. - The page has one thread. The notebook's section thread, sync worker and background run as tasks on its event loop (`notebook::task`), and work the desktop gives a thread runs once the frame is done (`spawn`). diff --git a/tools/TESTING.md b/tools/TESTING.md index 3ee0b31210300802f28fe0e41e2b2e280ee44568..6bb601c5efe5a96de6eb12b61f95bbe4a2881618 100644 --- a/tools/TESTING.md +++ b/tools/TESTING.md @@ -19,7 +19,7 @@ another, and the exit status is the result. | `windows-x86_64` | `platform/windows/cargo.sh` build of `snowbound` (nightly's win7 target) | | `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 | | `ios` | `xcodebuild` of the simulator app, unsigned | -| `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 | +| `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 | | `macos-10.6` | `platform/snow-leopard/cargo.sh` build of `snowbound`; skipped, saying why, without the SDK or nightly `rust-src` | ```sh diff --git a/tools/release_web.py b/tools/release_web.py index f086a989e3bd8276d0d117f82f05a22aafd4adee..3650938dbe0bd5ce016a3fe663026f1babb60c4e 100644 --- a/tools/release_web.py +++ b/tools/release_web.py @@ -1,9 +1,14 @@ #!/usr/bin/env python3 """Builds Snowbound for the web (crates/snowbound for wasm32, with crates/snowbound/web) as a -static folder and publishes it to the share's web/ folder. +static folder and deploys it to the VPS, which serves it at https://snowbound.paperclover.net. python3 tools/release_web.py --out /tmp/snowbound-web/dist # build only - python3 tools/release_web.py --publish # build, then replace web/ + python3 tools/release_web.py --deploy # build, then replace the site + +The module and its JavaScript go in a folder named by their contents, b/HASH/, which +index.html names, so a page never pairs one build's module with another's JavaScript; the +server (tools/web/serve.py, which pm2's `snowbound-web` runs as `serve.py 23593 site` in the +VPS's snowbound-web/) has index.html checked on every load and keeps a build's folder for good. """ import argparse import gzip @@ -26,9 +31,13 @@ WEB = ROOT / 'crates/snowbound/web' FONTS = ROOT / 'crates/canvas/assets/fonts' # Where the loading shell's icons are looked up by name, in order. ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets'] -PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web') -# copyparty lists the folder itself, so the app is at its index.html. -URL = 'https://file.paperclover.net/shr/snowbound/web/index.html' +# The VPS's folder: serve.py, and the site it serves on 127.0.0.1:23593 as pm2's +# `snowbound-web`, behind https://snowbound.paperclover.net. +HOST = 'clo@paperclover.net' +DEPLOYED = 'snowbound-web' +URL = 'https://snowbound.paperclover.net' +# Builds the server keeps beside the newest, for pages still running one. +KEPT = 3 EMOJI_URL = 'https://github.com/googlefonts/noto-emoji/raw/v2.051/fonts/Noto-COLRv1.ttf' EMOJI_SHA256 = '0ae57fe58645638523ba35f388d93739d292539a9acb84df5700c81b1e1a28d2' # As `EMOJI` in src/web.rs names it. @@ -91,25 +100,32 @@ def build(out): shutil.rmtree(out) out.mkdir(parents=True) module = TARGET / 'wasm32-unknown-unknown/release/snowbound.wasm' + code = out / 'b/next' run([tool('wasm-bindgen'), '--target', 'web', '--no-typescript', '--out-name', 'snowbound_web', - '--out-dir', out, module]) - bound = out / 'snowbound_web_bg.wasm' + '--out-dir', code, module]) + bound = code / 'snowbound_web_bg.wasm' run([tool('wasm-opt', 'binaryen'), '-Oz', '--strip-debug', '--strip-producers', bound, '-o', bound]) + digest = hashlib.sha256() + for path in sorted(code.rglob('*')): + if path.is_file(): + digest.update(str(path.relative_to(code)).encode() + b'\0' + path.read_bytes()) + code = code.rename(out / 'b' / digest.hexdigest()[:16]) dictionaries(out / 'dictionaries') (out / 'fonts').mkdir() for font in sorted(FONTS.glob('*')): if font.suffix in ('.ttf', '.txt'): shutil.copy(font, out / 'fonts') fallbacks(out / 'fonts') - page(out) + page(out, code.relative_to(out).as_posix()) for path in sorted(out.rglob('*')): if path.is_file(): print(f'{path.stat().st_size:>12,} {path.relative_to(out)}') -def page(out): - """Writes index.html with its loading shell's icons inlined, and the sizes of the files - it fetches before the app starts, for its progress bar.""" +def page(out, code): + """Writes index.html with its loading shell's icons inlined, the folder `code` it takes + the module and its JavaScript from, and the sizes of the files it fetches before the app + starts, for its progress bar.""" count = itertools.count() def inline(match): @@ -121,12 +137,15 @@ def page(out): svg = re.sub(r'url\(#([^)]+)\)', rf'url(#{prefix}\1)', svg) return f'{svg.strip()}' html = re.sub(r']*) icon="([^"]+)">', inline, (WEB / 'index.html').read_text()) - sizes = {str(path.relative_to(out)): path.stat().st_size - for path in [out / 'snowbound_web_bg.wasm', *sorted((out / 'fonts').glob('*.ttf'))]} - marker = 'const SIZES = {};' - if marker not in html: - sys.exit(f'index.html lacks `{marker}`') - (out / 'index.html').write_text(html.replace(marker, f'const SIZES = {json.dumps(sizes)};')) + sizes = {path.relative_to(out).as_posix(): path.stat().st_size + for path in [out / code / 'snowbound_web_bg.wasm', *sorted((out / 'fonts').glob('*.ttf'))]} + for marker, written in [('const SIZES = {};', f'const SIZES = {json.dumps(sizes)};'), + ('"./snowbound_web.js"', f'"./{code}/snowbound_web.js"'), + ('const MODULE = "snowbound_web_bg.wasm";', f'const MODULE = "{code}/snowbound_web_bg.wasm";')]: + if marker not in html: + sys.exit(f'index.html lacks `{marker}`') + html = html.replace(marker, written) + (out / 'index.html').write_text(html) def dictionaries(folder): @@ -187,32 +206,31 @@ def fallbacks(fonts): (fonts / 'Noto-OFL.txt').write_text('Noto fonts: SIL Open Font License 1.1, https://openfontlicense.org\n') -def publish(built): - """Replaces the share's web/ folder with `built`, leaving every other folder alone.""" - if not PUBLISHED.parent.is_dir(): - sys.exit(f'{PUBLISHED.parent} is not mounted') - staging = PUBLISHED.with_name('.web-staging') - if staging.exists(): - shutil.rmtree(staging) - shutil.copytree(built, staging) - if PUBLISHED.exists(): - shutil.rmtree(PUBLISHED) - staging.rename(PUBLISHED) - print(f'Published {URL}') +def deploy(built): + """Makes the VPS's site `built`, each file taking its place once all have arrived, and + leaves the last builds' folders for pages still running them.""" + sync = ['rsync', '--recursive', '--links', '--times', '--compress', '--itemize-changes'] + server = subprocess.run([*sync, ROOT / 'tools/web/serve.py', f'{HOST}:{DEPLOYED}/'], + check=True, capture_output=True, text=True).stdout + run([*sync, '--delete', '--delay-updates', '--filter=P b/*', f'{built}/', f'{HOST}:{DEPLOYED}/site/']) + run(['ssh', HOST, f'cd {DEPLOYED}/site/b && ls -t | tail -n +{KEPT + 2} | xargs -r rm -rf --']) + if server.strip(): + run(['ssh', HOST, 'pm2 restart snowbound-web']) + print(f'Deployed {URL}') def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--out', type=Path, help='Build into this folder (default: a temporary one)') - parser.add_argument('--publish', action='store_true', help=f'Copy the build to {PUBLISHED}') + parser.add_argument('--deploy', action='store_true', help=f'Copy the build to {DEPLOYED}') args = parser.parse_args() - if not args.out and not args.publish: - parser.error('give --out, --publish or both') + if not args.out and not args.deploy: + parser.error('give --out, --deploy or both') with tempfile.TemporaryDirectory() as scratch: out = args.out or Path(scratch) / 'web' build(out) - if args.publish: - publish(out) + if args.deploy: + deploy(out) if __name__ == '__main__': diff --git a/tools/web/serve.py b/tools/web/serve.py new file mode 100644 index 0000000000000000000000000000000000000000..395c2e1d07a98c2960e88af376c503535067b8ef --- /dev/null +++ b/tools/web/serve.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Serves the web build on 127.0.0.1, behind the VPS's Caddy and Cloudflare: index.html is +checked every load, a build's folder (b/HASH/) is kept for good, and the fonts and +dictionaries for a day. + + python3 serve.py PORT FOLDER +""" +import functools +import http.server +import sys + + +class Handler(http.server.SimpleHTTPRequestHandler): + extensions_map = {**http.server.SimpleHTTPRequestHandler.extensions_map, '.wasm': 'application/wasm'} + + def end_headers(self): + path = self.path.split('?')[0] + if path.startswith('/b/'): + cache = 'public, max-age=31536000, immutable' + elif path in ('/', '/index.html'): + cache = 'no-cache' + else: + cache = 'public, max-age=86400' + self.send_header('Cache-Control', cache) + super().end_headers() + + +if __name__ == '__main__': + port, folder = int(sys.argv[1]), sys.argv[2] + handler = functools.partial(Handler, directory=folder) + http.server.ThreadingHTTPServer(('127.0.0.1', port), handler).serve_forever()