62 lines
3.7 KiB
Python
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()
|