Spaces:
Paused
Paused
Download scripts/space_server.py from DigitalMetal/ace-mini-1: direct link, hf CLI and curl.
- Browser
- Download file 7.72 kB
-
https://huggingface.co/spaces/DigitalMetal/ace-mini-1/resolve/main/scripts/space_server.py
- Command line
-
hf download hf://spaces/DigitalMetal/ace-mini-1/scripts/space_server.py
-
curl -L -o space_server.py https://huggingface.co/spaces/DigitalMetal/ace-mini-1/resolve/main/scripts/space_server.py
7.72 kB
| """Single-process public gateway. Backend is loopback-only; uploads are session-owned.""" | |
| import asyncio, contextlib, hashlib, hmac, json, os, re, secrets, shutil, subprocess, sys, tempfile, time | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| import httpx | |
| from fastapi import FastAPI, Request | |
| from fastapi.responses import JSONResponse, RedirectResponse, Response | |
| from fastapi.staticfiles import StaticFiles | |
| ROOT=Path(__file__).resolve().parents[1] | |
| PUBLIC=Path(os.environ.get('ACE_PUBLIC_DIR',ROOT/'public')) | |
| KEY=secrets.token_bytes(32);owners={};busy=set();gate=asyncio.Semaphore(1);waiting=0 | |
| inflight=0 | |
| TTL=7200;MAX_UPLOAD=50*1024*1024 | |
| backend_url=os.environ.get('ACE_BACKEND_URL','http://127.0.0.1:9877') | |
| client=None;store=None | |
| def sign(n):return hmac.new(KEY,n.encode(),hashlib.sha256).hexdigest() | |
| def session(req): | |
| token=req.cookies.get('ace_session','');bits=token.split('.') | |
| if len(bits)==2 and re.fullmatch('[a-f0-9]{64}',bits[0]) and hmac.compare_digest(sign(bits[0]),bits[1]):return bits[0],None | |
| n=secrets.token_hex(32);return n,n+'.'+sign(n) | |
| def remove_job(j): | |
| if store is None:return | |
| target=(store/j).resolve() | |
| if target.parent!=store.resolve() or not re.fullmatch('[a-f0-9]{32}',j):raise ValueError('Invalid cleanup target') | |
| if target.exists():shutil.rmtree(target) | |
| owners.pop(j,None) | |
| async def cleanup(): | |
| while True: | |
| await asyncio.sleep(60) | |
| for j,entry in list(owners.items()): | |
| if j not in busy and time.time()-entry['created']>TTL:remove_job(j) | |
| async def lifespan(app): | |
| global client,store | |
| with tempfile.TemporaryDirectory(prefix='ace-public-') as temp: | |
| store=Path(temp) | |
| env={**os.environ,'ACE_PORT':'9877','ACE_STORE':temp,'PYTHONUNBUFFERED':'1'} | |
| proc=subprocess.Popen([sys.executable,str(ROOT/'scripts/casting_app.py')],env=env) | |
| client=httpx.AsyncClient(base_url=backend_url,timeout=420) | |
| task=None | |
| try: | |
| for _ in range(240): | |
| if proc.poll() is not None:raise RuntimeError('Inference backend failed to start') | |
| try: | |
| r=await client.get('/api/model') | |
| if r.status_code==200:break | |
| except httpx.HTTPError:pass | |
| await asyncio.sleep(1) | |
| else:raise RuntimeError('Inference backend startup timed out') | |
| task=asyncio.create_task(cleanup());yield | |
| finally: | |
| if task: | |
| task.cancel() | |
| with contextlib.suppress(asyncio.CancelledError):await task | |
| await client.aclose();proc.terminate() | |
| try:proc.wait(timeout=10) | |
| except subprocess.TimeoutExpired:proc.kill();proc.wait() | |
| app=FastAPI(lifespan=lifespan,docs_url=None,redoc_url=None,openapi_url=None) | |
| async def identity(req,call_next): | |
| global inflight | |
| limited=req.method=='POST' and req.url.path.startswith('/api/') | |
| if limited and inflight>=4:return JSONResponse({'error':'Service busy; please retry shortly.'},status_code=429) | |
| if limited:inflight+=1 | |
| sid,cookie=session(req);req.state.sid=sid | |
| try:response=await call_next(req) | |
| finally: | |
| if limited:inflight-=1 | |
| if cookie:response.set_cookie('ace_session',cookie,httponly=True,secure=req.url.scheme=='https',samesite='lax',max_age=TTL) | |
| if req.url.path.startswith('/api'):response.headers['Cache-Control']='no-store' | |
| response.headers['X-Content-Type-Options']='nosniff' | |
| return response | |
| async def home():return RedirectResponse('/artifacts/whitepaper/index.html') | |
| async def app_root():return RedirectResponse('/app/') | |
| async def health():return {'status':'ready'} | |
| def err(message,code=400):return JSONResponse({'error':message},status_code=code) | |
| def owned(j,sid):return j in owners and owners[j]['owner']==sid and time.time()-owners[j]['created']<=TTL | |
| def forward(r): | |
| # httpx decompresses; do not forward Content-Encoding/Length from upstream. | |
| return Response(r.content,status_code=r.status_code,media_type=r.headers.get('content-type','application/json')) | |
| async def api(route,req:Request): | |
| global waiting | |
| sid=req.state.sid | |
| if req.method=='GET': | |
| if route=='model':return forward(await client.get('/api/model')) | |
| if route=='recent':return [{'id':j,'name':e['name']} for j,e in owners.items() if owned(j,sid)] | |
| if not re.fullmatch(r'(job|geometry)/[a-f0-9]{32}',route):return err('Not found',404) | |
| if not owned(route.split('/')[-1],sid):return err('Part not found or session expired',404) | |
| return forward(await client.get('/api/'+route)) | |
| # Browser requests must originate from this host; no cross-origin upload API. | |
| origin=req.headers.get('origin') | |
| if origin and origin.rstrip('/')!=str(req.base_url).rstrip('/'):return err('Origin not allowed',403) | |
| if route not in ['upload','resume','predict']:return err('Not found',404) | |
| raw=bytearray();limit=MAX_UPLOAD if route=='upload' else 65536 | |
| async for chunk in req.stream(): | |
| raw.extend(chunk) | |
| if len(raw)>limit:return err('Request exceeds size limit',413) | |
| if route=='upload': | |
| if len(owners)>=24 or sum(owned(j,sid) for j in owners)>=3:return err('Upload limit reached. Temporary parts expire after two hours.',429) | |
| if b'ISO-10303-21' not in raw[:4096].upper():return err('Expected a STEP/STP file') | |
| else: | |
| try:opt=json.loads(raw);j=opt['id'] | |
| except (ValueError,KeyError,TypeError):return err('Invalid request') | |
| if not isinstance(j,str) or not owned(j,sid):return err('Part not found or session expired',404) | |
| if route=='resume':return forward(await client.post('/api/resume',content=bytes(raw))) | |
| if waiting>=4:return err('Prediction service is busy. Please retry shortly.',429) | |
| waiting+=1;j=None | |
| try: | |
| async with gate: | |
| headers={'Accept':req.headers.get('accept','application/json')} | |
| if route=='upload': | |
| if len(owners)>=24 or sum(owned(k,sid) for k in owners)>=3:return err('Upload limit reached',429) | |
| name=req.headers.get('x-filename','Uploaded STEP')[:200];headers['X-Filename']=name | |
| r=await client.post('/api/upload',content=bytes(raw),headers=headers) | |
| if r.status_code!=200:return forward(r) | |
| result=r.json();j=result['id'];owners[j]={'owner':sid,'created':time.time(),'name':name};busy.add(j) | |
| # Keep CAD and inference serialized; browser shows its preparing state. | |
| for _ in range(390): | |
| state=await client.get('/api/job/'+j) | |
| if state.json().get('status')!='processing':return forward(state) | |
| await asyncio.sleep(1) | |
| return err('Geometry preparation timed out',504) | |
| j=opt['id'] | |
| if not owned(j,sid):return err('Part expired',404) | |
| busy.add(j) | |
| return forward(await client.post('/api/predict',content=bytes(raw),headers=headers)) | |
| except httpx.HTTPError:return err('Prediction service unavailable; please retry',503) | |
| finally: | |
| waiting-=1 | |
| if j:busy.discard(j) | |
| # Only explicitly packaged assets are served. No repository or job-directory mounts. | |
| app.mount('/app',StaticFiles(directory=PUBLIC/'artifacts/casting_app',html=True),name='app') | |
| app.mount('/artifacts',StaticFiles(directory=PUBLIC/'artifacts'),name='showcase') | |