#!/usr/bin/env python3 """Static file server with HTTP Range support, for the OpenRA3 wasm build. Emscripten's lazy file reads stream assets back as byte ranges, so the server must honour the ``Range`` header -- Python's stdlib ``http.server`` does not. python3 apps/web/serve.py --root build/wasm/bin --port 8199 # then open http://localhost:8199/index.html Serve ``--root`` containing ``index.html``, ``openra3.js``, ``openra3.wasm``, ``openra3.worker.js`` and ``assets.manifest.json``. The lazy asset tree is mounted from ``--assets`` (its files are then fetched from ``/assets/`` on demand); without ``--assets`` the tree is expected under ``--root`` itself. """ import argparse import functools import os import re import sys from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer _RANGE = re.compile(r"bytes=(\d*)-(\d*)$") class RangeHandler(SimpleHTTPRequestHandler): # Directory served for ``/assets/...`` (set in main); None means "under root". assets_dir = None def translate_path(self, path): if self.assets_dir: prefix = "/assets/" if path == "/assets" or path.startswith(prefix): rel = path[len(prefix):] if path.startswith(prefix) else "" rel = rel.split("?", 1)[0].split("#", 1)[0] parts = [p for p in rel.split("/") if p and p != ".."] return os.path.join(self.assets_dir, *parts) return super().translate_path(path) # Cross-origin isolation (kept for SharedArrayBuffer-style features and the # WebGPU/pthread paths). no-store keeps the dev loop honest: a stale # openra3.js/.wasm in the browser cache hides new builds (the worker's # importScripts especially). def end_headers(self): self.send_header("Cross-Origin-Opener-Policy", "same-origin") self.send_header("Cross-Origin-Embedder-Policy", "require-corp") self.send_header("Cache-Control", "no-store, max-age=0") super().end_headers() def send_head(self): header = self.headers.get("Range") if not header: return super().send_head() path = self.translate_path(self.path) if os.path.isdir(path): return super().send_head() try: handle = open(path, "rb") except OSError: self.send_error(404, "File not found") return None match = _RANGE.match(header.strip()) if not match: handle.close() return super().send_head() size = os.fstat(handle.fileno()).st_size start = int(match.group(1)) if match.group(1) else 0 end = int(match.group(2)) if match.group(2) else size - 1 if start > end or start >= size: handle.close() self.send_error(416, "Requested Range Not Satisfiable") return None end = min(end, size - 1) self.send_response(206) self.send_header("Content-Type", self.guess_type(path)) self.send_header("Accept-Ranges", "bytes") self.send_header("Content-Range", f"bytes {start}-{end}/{size}") self.send_header("Content-Length", str(end - start + 1)) self.send_header("Cache-Control", "no-store") self.end_headers() handle.seek(start) self._remaining = end - start + 1 return handle def copyfile(self, source, outputfile): remaining = getattr(self, "_remaining", None) if remaining is None: return super().copyfile(source, outputfile) try: while remaining > 0: chunk = source.read(min(65536, remaining)) if not chunk: break outputfile.write(chunk) remaining -= len(chunk) finally: self._remaining = None def main() -> int: parser = argparse.ArgumentParser(description="OpenRA3 wasm dev server") parser.add_argument("--root", default="build/wasm/bin", help="directory to serve") parser.add_argument("--assets", default=None, help="directory served for /assets (the lazy asset tree)") parser.add_argument("--port", type=int, default=8199) parser.add_argument("--bind", default="127.0.0.1") args = parser.parse_args() if not os.path.isdir(args.root): print(f"root not found: {args.root}", file=sys.stderr) return 1 if args.assets and not os.path.isdir(args.assets): print(f"assets not found: {args.assets}", file=sys.stderr) return 1 RangeHandler.assets_dir = os.path.abspath(args.assets) if args.assets else None handler = functools.partial(RangeHandler, directory=args.root) server = ThreadingHTTPServer((args.bind, args.port), handler) print(f"serving {args.root} at http://{args.bind}:{args.port}/index.html") if RangeHandler.assets_dir: print(f" /assets -> {RangeHandler.assets_dir}") try: server.serve_forever() except KeyboardInterrupt: pass return 0 if __name__ == "__main__": raise SystemExit(main())