| ... | ... | @@ -1,9 +1,14 @@ |
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | """Builds Snowbound for the web (crates/snowbound for wasm32, with crates/snowbound/web) as a |
| 3 | | static folder and publishes it to the share's web/ folder. |
| 3 | static folder and deploys it to the VPS, which serves it at https://snowbound.paperclover.net. |
| 4 | 4 | |
| 5 | 5 | 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 | |
| 8 | The module and its JavaScript go in a folder named by their contents, b/HASH/, which |
| 9 | index.html names, so a page never pairs one build's module with another's JavaScript; the |
| 10 | server (tools/web/serve.py, which pm2's `snowbound-web` runs as `serve.py 23593 site` in the |
| 11 | VPS's snowbound-web/) has index.html checked on every load and keeps a build's folder for good. |
| 7 | 12 | """ |
| 8 | 13 | import argparse |
| 9 | 14 | import gzip |
| ... | ... | @@ -26,9 +31,13 @@ WEB = ROOT / 'crates/snowbound/web' |
| 26 | 31 | FONTS = ROOT / 'crates/canvas/assets/fonts' |
| 27 | 32 | # Where the loading shell's icons are looked up by name, in order. |
| 28 | 33 | ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets'] |
| 29 | | PUBLISHED = Path('/Volumes/clover/Documents/Public/Snowbound/web') |
| 30 | | # copyparty lists the folder itself, so the app is at its index.html. |
| 31 | | URL = '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. |
| 36 | HOST = 'clo@paperclover.net' |
| 37 | DEPLOYED = 'snowbound-web' |
| 38 | URL = 'https://snowbound.paperclover.net' |
| 39 | # Builds the server keeps beside the newest, for pages still running one. |
| 40 | KEPT = 3 |
| 32 | 41 | EMOJI_URL = 'https://github.com/googlefonts/noto-emoji/raw/v2.051/fonts/Noto-COLRv1.ttf' |
| 33 | 42 | EMOJI_SHA256 = '0ae57fe58645638523ba35f388d93739d292539a9acb84df5700c81b1e1a28d2' |
| 34 | 43 | # As `EMOJI` in src/web.rs names it. |
| ... | ... | @@ -91,25 +100,32 @@ def build(out): |
| 91 | 100 | shutil.rmtree(out) |
| 92 | 101 | out.mkdir(parents=True) |
| 93 | 102 | module = TARGET / 'wasm32-unknown-unknown/release/snowbound.wasm' |
| 103 | code = out / 'b/next' |
| 94 | 104 | 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' |
| 97 | 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 | 113 | dictionaries(out / 'dictionaries') |
| 99 | 114 | (out / 'fonts').mkdir() |
| 100 | 115 | for font in sorted(FONTS.glob('*')): |
| 101 | 116 | if font.suffix in ('.ttf', '.txt'): |
| 102 | 117 | shutil.copy(font, out / 'fonts') |
| 103 | 118 | fallbacks(out / 'fonts') |
| 104 | | page(out) |
| 119 | page(out, code.relative_to(out).as_posix()) |
| 105 | 120 | for path in sorted(out.rglob('*')): |
| 106 | 121 | if path.is_file(): |
| 107 | 122 | print(f'{path.stat().st_size:>12,} {path.relative_to(out)}') |
| 108 | 123 | |
| 109 | 124 | |
| 110 | | def 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.""" |
| 125 | def 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.""" |
| 113 | 129 | count = itertools.count() |
| 114 | 130 | |
| 115 | 131 | def inline(match): |
| ... | ... | @@ -121,12 +137,15 @@ def page(out): |
| 121 | 137 | svg = re.sub(r'url\(#([^)]+)\)', rf'url(#{prefix}\1)', svg) |
| 122 | 138 | return f'<i{match[1]}>{svg.strip()}</i>' |
| 123 | 139 | 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) |
| 130 | 149 | |
| 131 | 150 | |
| 132 | 151 | def dictionaries(folder): |
| ... | ... | @@ -187,32 +206,31 @@ def fallbacks(fonts): |
| 187 | 206 | (fonts / 'Noto-OFL.txt').write_text('Noto fonts: SIL Open Font License 1.1, https://openfontlicense.org\n') |
| 188 | 207 | |
| 189 | 208 | |
| 190 | | def 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}') |
| 209 | def 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}') |
| 202 | 220 | |
| 203 | 221 | |
| 204 | 222 | def main(): |
| 205 | 223 | parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 206 | 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 | 226 | 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') |
| 211 | 229 | with tempfile.TemporaryDirectory() as scratch: |
| 212 | 230 | out = args.out or Path(scratch) / 'web' |
| 213 | 231 | build(out) |
| 214 | | if args.publish: |
| 215 | | publish(out) |
| 232 | if args.deploy: |
| 233 | deploy(out) |
| 216 | 234 | |
| 217 | 235 | |
| 218 | 236 | if __name__ == '__main__': |