Files

62 lines
3.7 KiB
Python

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