"""A local, read-only live viewer. Authoring remains in Python/CLI transactions.""" import hashlib import json import threading from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer from pathlib import Path from urllib.parse import urlsplit from .kernel import build,operator_catalog from .project import read_json def make_server(project_path,port=8767): path=Path(project_path).resolve();assets=Path(__file__).parent/'web' state={'source_hash':None,'bundle':None,'error':None};lock=threading.Lock() def current(): with lock: try: with path.open('rb') as f:raw=f.read(64*1024*1024+1) if len(raw)>64*1024*1024:raise ValueError('JSON input exceeds 64 MiB') key=hashlib.sha256(raw).hexdigest() if key!=state['source_hash']: state['source_hash']=key try:state['bundle']=build(json.loads(raw));state['error']=None except (ValueError,TypeError,KeyError) as e:state['error']=str(e) except (OSError,ValueError) as e:state['error']=str(e) return state.copy() class Handler(BaseHTTPRequestHandler): def log_message(self,fmt,*args):pass def send(self,data,kind='application/json',status=200,etag=None,download=None): body=data if isinstance(data,bytes) else json.dumps(data,allow_nan=False).encode() self.send_response(status);self.send_header('Content-Type',kind);self.send_header('Content-Length',str(len(body))) self.send_header('Cache-Control','no-cache');self.send_header('X-Content-Type-Options','nosniff') self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:") if etag:self.send_header('ETag',etag) if download:self.send_header('Content-Disposition',f'attachment; filename="{download}"') self.end_headers() try:self.wfile.write(body) except (BrokenPipeError,ConnectionResetError):pass def do_GET(self): route=urlsplit(self.path).path if route in ['/','/app.js','/style.css']: name='index.html' if route=='/' else route[1:];kind={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8'}[Path(name).suffix] self.send((assets/name).read_bytes(),kind);return if route=='/api/operators':self.send(operator_catalog());return if route in ['/api/bundle','/export.bundle.json','/api/status']: result=current() if result['error']:self.send({'error':result['error'],'source':path.name,'stale':bool(result['bundle'])},status=422);return b=result['bundle'];tag='"'+b['bundle_hash']+'"' if route=='/api/status':self.send({'source':path.name,'project_hash':b['project_hash'],'bundle_hash':b['bundle_hash'],'stats':b['stats']});return if self.headers.get('If-None-Match')==tag and route=='/api/bundle':self.send(b'',status=304,etag=tag);return self.send(b,etag=tag,download=b['project_id']+'.bundle.json' if route=='/export.bundle.json' else None);return self.send({'error':'Not found'},status=404) server=ThreadingHTTPServer(('127.0.0.1',port),Handler);server.daemon_threads=True return server def serve(path,port): build(read_json(path)) server=make_server(path,port) print(f'Spatial Lab: http://127.0.0.1:{server.server_port}\nWatching: {Path(path).resolve()}',flush=True) try:server.serve_forever() except KeyboardInterrupt:pass finally:server.server_close()