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()