| ... | @@ -1,9 +1,14 @@ | ... | @@ -1,9 +1,14 @@ |
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | """Builds Snowbound for the web (crates/snowbound for wasm32, with crates/snowbound/web) as a | 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 | python3 tools/release_web.py --out /tmp/snowbound-web/dist # build only | 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 | import argparse | 13 | import argparse |
| 9 | import gzip | 14 | import gzip |
| ... | @@ -26,9 +31,13 @@ WEB = ROOT / 'crates/snowbound/web' | ... | @@ -26,9 +31,13 @@ WEB = ROOT / 'crates/snowbound/web' |
| 26 | FONTS = ROOT / 'crates/canvas/assets/fonts' | 31 | FONTS = 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. |
| 28 | ICONS = [ROOT / 'crates/snowbound/assets/icons', ROOT / 'crates/canvas/assets/tags', ROOT / 'crates/ui/assets'] | 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') | 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. |
| 31 | URL = 'https://file.paperclover.net/shr/snowbound/web/index.html' | 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 | EMOJI_URL = 'https://github.com/googlefonts/noto-emoji/raw/v2.051/fonts/Noto-COLRv1.ttf' | 41 | EMOJI_URL = 'https://github.com/googlefonts/noto-emoji/raw/v2.051/fonts/Noto-COLRv1.ttf' |
| 33 | EMOJI_SHA256 = '0ae57fe58645638523ba35f388d93739d292539a9acb84df5700c81b1e1a28d2' | 42 | EMOJI_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)}') |
| 108 | | 123 | |
| 109 | | 124 | |
| 110 | def page(out): | 125 | def page(out, code): |
| 111 | """Writes index.html with its loading shell's icons inlined, and the sizes of the files | 126 | """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() |
| 114 | | 130 | |
| 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_size | 140 | 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) |
| 130 | | 149 | |
| 131 | | 150 | |
| 132 | def dictionaries(folder): | 151 | def 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') |
| 188 | | 207 | |
| 189 | | 208 | |
| 190 | def publish(built): | 209 | def 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}') | | |
| 202 | | 220 | |
| 203 | | 221 | |
| 204 | def main(): | 222 | def 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) |
| 216 | | 234 | |
| 217 | | 235 | |
| 218 | if __name__ == '__main__': | 236 | if __name__ == '__main__': |