@rig-c/review 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +126 -7
- package/docs/DESIGN.md +29 -0
- package/docs/REVIEW_PACKAGE.md +24 -5
- package/package.json +5 -3
- package/src/animation-selection.ts +9 -5
- package/src/cli.ts +9 -6
- package/src/compare-server.ts +8 -5
- package/src/comparison-eligibility.ts +54 -0
- package/src/control.ts +32 -3
- package/src/front-worker.ts +2 -0
- package/src/front.ts +127 -0
- package/src/manifest.ts +5 -1
- package/src/open-protocol.ts +17 -0
- package/src/open.ts +67 -34
- package/src/server.ts +8 -7
- package/src/strip-render.ts +1 -1
- package/web/camera-view.ts +10 -0
- package/web/compare.css +44 -0
- package/web/compare.html +3 -3
- package/web/compare.ts +78 -34
- package/web/control.ts +6 -4
- package/web/index.html +3 -3
- package/web/inspection-persistence.ts +30 -11
- package/web/instance-access.ts +23 -0
- package/web/main.ts +85 -41
- package/web/part-selection.ts +136 -0
- package/web/rig-overlays.ts +9 -6
- package/web/styles.css +41 -0
- package/web/view-controls.ts +7 -5
- package/web/workspace-layout.ts +84 -0
- package/web/workspace-preferences.ts +95 -0
- package/web/workspace-ui.ts +91 -0
package/src/front.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** Managed loopback front: one stable origin (127.0.0.1:4318 by default) routing `/instances/<key>/…` to the
|
|
2
|
+
* worker named by that key's private open record. It is a router, not an authority: it never reads packages,
|
|
3
|
+
* never serves or logs a capability, and forwards a mutation (rewriting Origin) only with the page capability. */
|
|
4
|
+
import {randomUUID} from 'node:crypto';
|
|
5
|
+
import {spawn} from 'node:child_process';
|
|
6
|
+
import {lstat,readFile,readdir,realpath,rename,unlink,writeFile,open as openFile} from 'node:fs/promises';
|
|
7
|
+
import {dirname,join} from 'node:path';
|
|
8
|
+
import {fileURLToPath} from 'node:url';
|
|
9
|
+
import {acquireClaim,alive,openFingerprint,readRecord,releaseClaim,running,smallJson,type RecordFile} from './open.ts';
|
|
10
|
+
import {capabilityHeader,openKeyHeader,openNonceHeader,sameSecret} from './open-protocol.ts';
|
|
11
|
+
export const defaultFrontPort=4318; // Foreground `review <dir>` keeps 4317.
|
|
12
|
+
interface FrontRecord {schema:'rig-review-front/1';origin:string;port:number;nonce:string;fingerprint:string;pid:number}
|
|
13
|
+
interface FrontRequest {schema:'rig-review-front-request/1';control:string;request:string;record:string;port:number;nonce:string;fingerprint:string;idleMs:number}
|
|
14
|
+
const appRoot=fileURLToPath(new URL('..',import.meta.url));
|
|
15
|
+
const sleep=(ms:number)=>new Promise(resolveSleep=>setTimeout(resolveSleep,ms));
|
|
16
|
+
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/,hex64=/^[a-f0-9]{64}$/;
|
|
17
|
+
const frontName=(port:number)=>`front-${port}`;
|
|
18
|
+
const noStore={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer'};
|
|
19
|
+
const refuse=(error:string,status:number)=>Response.json({error},{status,headers:noStore});
|
|
20
|
+
export function frontPort(value?:number):number{const raw=value??(process.env.RIG_REVIEW_FRONT_PORT===undefined?defaultFrontPort:Number(process.env.RIG_REVIEW_FRONT_PORT));if(!Number.isSafeInteger(raw)||raw<1||raw>65535)throw new Error('FRONT_PORT: RIG_REVIEW_FRONT_PORT expects an integer 1..65535');return raw;}
|
|
21
|
+
function frontValue(value:unknown,port:number):FrontRecord|null{if(!value||typeof value!=='object'||Array.isArray(value))return null;const v=value as Partial<FrontRecord>;return v.schema==='rig-review-front/1'&&v.port===port&&v.origin===`http://127.0.0.1:${port}`&&uuid.test(v.nonce??'')&&hex64.test(v.fingerprint??'')&&Number.isSafeInteger(v.pid)&&v.pid!>0?v as FrontRecord:null;}
|
|
22
|
+
async function readFront(file:string,port:number):Promise<FrontRecord|null>{try{return frontValue(await smallJson(file),port);}catch(e){if(['ENOENT','ELOOP'].includes((e as NodeJS.ErrnoException).code??'')||e instanceof SyntaxError)return null;throw e;}}
|
|
23
|
+
async function frontAlive(row:FrontRecord):Promise<boolean>{try{const response=await fetch(row.origin+'/api/front-status',{headers:{authorization:'Bearer '+row.nonce},redirect:'error',signal:AbortSignal.timeout(1500)});if(!response.ok){void response.body?.cancel();return false;}const v=await response.json() as {schema?:string;nonce?:string;fingerprint?:string};return v.schema==='rig-review-front-status/1'&&v.nonce===row.nonce&&v.fingerprint===row.fingerprint;}catch{return false;}}
|
|
24
|
+
/** Ensure this state directory's front on `port`; reuse a verified live one. A front from another tool
|
|
25
|
+
* build is ours (verified nonce) and is replaced. Any other listener is FRONT_PORT_BUSY, never a fallback. */
|
|
26
|
+
export async function ensureFront(options:{control:string;port:number;fingerprint:string;idleMs?:number}):Promise<string>{
|
|
27
|
+
const {control,port,fingerprint}=options,name=frontName(port),recordPath=join(control,name+'.json'),nonce=randomUUID(),deadline=performance.now()+20000,idleMs=options.idleMs??30*60*1000;
|
|
28
|
+
const reuse=async()=>{const row=await readFront(recordPath,port);if(!row||!await frontAlive(row))return null;if(row.fingerprint===fingerprint)return row;
|
|
29
|
+
try{process.kill(row.pid,'SIGTERM');}catch{}for(let n=0;n<100&&await frontAlive(row);n++)await sleep(30);return null;};
|
|
30
|
+
const ready=await reuse();if(ready)return ready.origin;
|
|
31
|
+
const claimed=await acquireClaim(control,name,nonce,reuse,deadline,'FRONT_BUSY: front startup claim did not become ready');if('row' in claimed)return claimed.row.origin;
|
|
32
|
+
let request:string|undefined,child:ReturnType<typeof spawn>|undefined,handedOff=false;
|
|
33
|
+
try{
|
|
34
|
+
const row=await reuse();if(row)return row.origin;
|
|
35
|
+
request=join(control,`${name}.${nonce}.request.json`);const log=join(control,`${name}.${nonce}.log`);
|
|
36
|
+
const spec:FrontRequest={schema:'rig-review-front-request/1',control,request,record:recordPath,port,nonce,fingerprint,idleMs};await writeFile(request,JSON.stringify(spec),{mode:0o600,flag:'wx'});
|
|
37
|
+
const logHandle=await openFile(log,'a',0o600);try{child=spawn(process.execPath,[join(appRoot,'src/front-worker.ts'),request],{cwd:control,detached:true,stdio:['ignore',logHandle.fd,logHandle.fd],env:process.env});child.unref();}finally{await logHandle.close();}
|
|
38
|
+
let exited:string|undefined;child.on('error',error=>{exited=error.message;});child.on('exit',(code,signal)=>{exited=`exit ${code} ${signal??''}`;});
|
|
39
|
+
while(performance.now()<deadline){const live=await reuse();if(live){handedOff=true;return live.origin;}
|
|
40
|
+
if(exited){if(/FRONT_PORT_BUSY/.test(await readFile(log,'utf8').catch(()=>'')))throw new Error(`FRONT_PORT_BUSY: 127.0.0.1:${port} is held by another listener; use --direct (or set RIG_REVIEW_FRONT_PORT)`);throw new Error(`FRONT_START: ${exited}; front log ${log}`);}
|
|
41
|
+
await sleep(30);}
|
|
42
|
+
throw new Error(`FRONT_TIMEOUT: front did not become ready; log ${log}`);
|
|
43
|
+
}finally{if(child&&!handedOff&&child.exitCode===null&&child.signalCode===null)child.kill('SIGTERM');await releaseClaim(join(control,name+'.claim'),nonce);if(request)await unlink(request).catch(e=>{if(e.code!=='ENOENT')throw e;});}
|
|
44
|
+
}
|
|
45
|
+
interface Route {mutation:boolean;limit:number;timeoutMs:number;control:boolean}
|
|
46
|
+
/** Closed allowlist per method. Agent control endpoints, open-status and anything unlisted are refused. */
|
|
47
|
+
export function frontRoute(mode:'single'|'compare',method:string,path:string):Route|null{
|
|
48
|
+
const read=method==='GET'||method==='HEAD',get:Route={mutation:false,limit:0,timeoutMs:30000,control:false};
|
|
49
|
+
if(['/','/index.html','/main.js','/styles.css','/api/access'].includes(path))return read?get:null;
|
|
50
|
+
if(path==='/api/control/config')return method==='GET'?{...get,control:true}:null;
|
|
51
|
+
if(['/api/control/register','/api/control/poll','/api/control/ack','/api/control/close'].includes(path))return method==='POST'?{mutation:true,limit:16384,timeoutMs:10000,control:true}:null;
|
|
52
|
+
const session=mode==='single'?'/api/session':'/api/compare/session',strip=mode==='single'?'/api/strip':'/api/compare/strip';
|
|
53
|
+
if(path===session)return read?get:method==='PUT'?{mutation:true,limit:1024*1024,timeoutMs:30000,control:false}:null;
|
|
54
|
+
if(path===strip)return method==='POST'?{mutation:true,limit:1024*1024,timeoutMs:120000,control:false}:null;
|
|
55
|
+
const reads=mode==='single'?['/api/review','/review-reference']:['/api/compare','/api/compare/reference-frames','/compare-reference'];
|
|
56
|
+
if(reads.includes(path))return read?get:null;
|
|
57
|
+
if((mode==='single'?/^\/assets\/[^/]/:/^\/compare-assets\/(a|b)\/[^/]/).test(path))return read?get:null;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
async function boundedBody(req:Request,limit:number,timeoutMs=10000):Promise<ArrayBuffer>{
|
|
61
|
+
if(!/^application\/json(?:\s*;|$)/i.test(req.headers.get('content-type')??''))throw new Error('FRONT_JSON_REQUIRED');
|
|
62
|
+
const declared=req.headers.get('content-length');if(declared!==null&&(!/^\d+$/.test(declared)||Number(declared)>limit))throw new Error('FRONT_BODY_LIMIT');
|
|
63
|
+
if(!req.body)throw new Error('FRONT_JSON_REQUIRED');const reader=req.body.getReader();let timer:ReturnType<typeof setTimeout>|undefined;
|
|
64
|
+
try{return await Promise.race([(async()=>{const chunks:Uint8Array[]=[];let size=0;while(true){const next=await reader.read();if(next.done)break;size+=next.value.length;if(size>limit)throw new Error('FRONT_BODY_LIMIT');chunks.push(next.value);}const bytes=new Uint8Array(size);let at=0;for(const chunk of chunks){bytes.set(chunk,at);at+=chunk.length;}return bytes.buffer;})(),new Promise<never>((_,reject)=>{timer=setTimeout(()=>reject(new Error('FRONT_BODY_TIMEOUT')),timeoutMs);})]);}
|
|
65
|
+
finally{if(timer)clearTimeout(timer);void reader.cancel().catch(()=>{});}
|
|
66
|
+
}
|
|
67
|
+
/** Owner-only regular record for this key, or null. */
|
|
68
|
+
async function instanceRecord(control:string,key:string):Promise<RecordFile|null>{const file=join(control,key+'.json');try{const info=await lstat(file);if(!info.isFile()||(info.mode&0o077)!==0)return null;}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return null;throw e;}return readRecord(file,key);}
|
|
69
|
+
/** Worker statuses map to named front codes; worker text never reaches the client. */
|
|
70
|
+
const upstreamFailure:Record<number,string>={400:'FRONT_UPSTREAM_REJECTED',403:'FRONT_UPSTREAM_FORBIDDEN',404:'FRONT_UPSTREAM_NOT_FOUND',405:'FRONT_UPSTREAM_METHOD',409:'FRONT_UPSTREAM_CONFLICT'};
|
|
71
|
+
const responseHeaders=['content-type','content-length','content-range','accept-ranges','content-disposition','cache-control','content-security-policy','x-content-type-options','referrer-policy'];
|
|
72
|
+
export const maximumUpstreamStreams=32;
|
|
73
|
+
export function startFront(options:{control:string;port:number;nonce:string;fingerprint:string}){
|
|
74
|
+
const origin=`http://127.0.0.1:${options.port}`;let active=0,last=performance.now();
|
|
75
|
+
const server=Bun.serve({hostname:'127.0.0.1',port:options.port,async fetch(req){
|
|
76
|
+
last=performance.now();
|
|
77
|
+
try{
|
|
78
|
+
const url=new URL(req.url);if(url.origin!==origin)return refuse('FRONT_LOOPBACK_REQUIRED',403);
|
|
79
|
+
if(url.pathname==='/api/front-status'){if(req.method!=='GET'||!sameSecret(req.headers.get('authorization'),'Bearer '+options.nonce))return refuse('FRONT_STATUS_CAPABILITY_REQUIRED',403);return Response.json({schema:'rig-review-front-status/1',nonce:options.nonce,fingerprint:options.fingerprint},{headers:noStore});}
|
|
80
|
+
const match=/^\/instances\/([a-f0-9]{64})(\/.*)$/.exec(url.pathname);if(!match)return refuse('FRONT_ROUTE_REFUSED',404);
|
|
81
|
+
const key=match[1]!,path=match[2]!,sentOrigin=req.headers.get('origin');
|
|
82
|
+
if(req.headers.get('sec-fetch-site')==='cross-site'||sentOrigin!==null&&sentOrigin!==origin)return refuse('FRONT_SAME_ORIGIN_REQUIRED',403);
|
|
83
|
+
const record=await instanceRecord(options.control,key);if(!record)return refuse('FRONT_INSTANCE_UNKNOWN: reopen with rig-review open',404);
|
|
84
|
+
const route=frontRoute(record.mode,req.method,path);if(!route)return refuse('FRONT_ROUTE_REFUSED',404);
|
|
85
|
+
const landing=path==='/'||path==='/index.html';if(!landing&&url.search)return refuse('FRONT_QUERY_REFUSED',400);
|
|
86
|
+
let body:ArrayBuffer|undefined;
|
|
87
|
+
if(route.mutation){
|
|
88
|
+
// Never rewrite Origin without this instance's capability; the worker compares it again itself.
|
|
89
|
+
if(sentOrigin!==origin)return refuse('FRONT_SAME_ORIGIN_REQUIRED',403);
|
|
90
|
+
if(!sameSecret(req.headers.get(capabilityHeader),record.capability))return refuse('FRONT_CAPABILITY_REQUIRED: reopen with rig-review open to edit',403);
|
|
91
|
+
body=await boundedBody(req,route.limit);
|
|
92
|
+
if(!await alive(record))return refuse('FRONT_INSTANCE_EXPIRED: reopen with rig-review open',410);
|
|
93
|
+
}
|
|
94
|
+
if(active>=maximumUpstreamStreams)return refuse('FRONT_BUSY: too many concurrent upstream streams',503);
|
|
95
|
+
const headers=new Headers({[openNonceHeader]:record.nonce});for(const name of ['accept','range','if-range',...(route.mutation?['content-type']:[]),...(route.control?['authorization']:[])]){const value=req.headers.get(name);if(value!==null)headers.set(name,value);}
|
|
96
|
+
if(route.mutation){headers.set('origin',record.origin);headers.set(capabilityHeader,record.capability);}
|
|
97
|
+
active++;let released=false;const abort=new AbortController(),timer=setTimeout(()=>abort.abort(),route.timeoutMs);const release=()=>{if(released)return;released=true;active--;clearTimeout(timer);};
|
|
98
|
+
req.signal.addEventListener('abort',()=>abort.abort(),{once:true});
|
|
99
|
+
let upstream:Response;
|
|
100
|
+
try{upstream=await fetch(record.origin+path+(landing?url.search:''),{method:req.method,headers,...(body?{body}:{}),redirect:'manual',signal:abort.signal});}
|
|
101
|
+
catch{release();return abort.signal.aborted&&!req.signal.aborted?refuse('FRONT_UPSTREAM_TIMEOUT',504):refuse('FRONT_INSTANCE_EXPIRED: reopen with rig-review open',410);}
|
|
102
|
+
if(upstream.headers.get(openKeyHeader)!==key){void upstream.body?.cancel().catch(()=>{});release();return refuse('FRONT_INSTANCE_REPLACED: reopen with rig-review open',410);}
|
|
103
|
+
if(upstream.status>=400&&upstream.status!==416){void upstream.body?.cancel().catch(()=>{});release();const code=upstreamFailure[upstream.status];return refuse(code??'FRONT_UPSTREAM_FAILED',code?upstream.status:502);}
|
|
104
|
+
const out=new Headers();for(const name of responseHeaders){const value=upstream.headers.get(name);if(value!==null)out.set(name,value);}
|
|
105
|
+
if(!upstream.body||req.method==='HEAD'){void upstream.body?.cancel().catch(()=>{});release();return new Response(null,{status:upstream.status,headers:out});}
|
|
106
|
+
clearTimeout(timer);const reader=upstream.body.getReader();
|
|
107
|
+
// Media/model streams stay counted until finished or cancelled by the browser.
|
|
108
|
+
const stream=new ReadableStream<Uint8Array>({async pull(controller){try{const next=await reader.read();if(next.done){controller.close();release();}else controller.enqueue(next.value);}catch(error){release();controller.error(error);}},cancel(reason){release();abort.abort();return reader.cancel(reason).catch(()=>{});}});
|
|
109
|
+
return new Response(stream,{status:upstream.status,headers:out});
|
|
110
|
+
}catch(error){const message=error instanceof Error?error.message:'';return refuse(message.startsWith('FRONT_')?message:'FRONT_REQUEST_FAILED',message==='FRONT_BODY_LIMIT'?413:message==='FRONT_BODY_TIMEOUT'?408:400);}
|
|
111
|
+
}});
|
|
112
|
+
return {origin,stop:()=>server.stop(true),idleMs:()=>performance.now()-last,active:()=>active};
|
|
113
|
+
}
|
|
114
|
+
async function liveInstances(control:string):Promise<boolean>{for(const name of await readdir(control))if(/^[a-f0-9]{64}\.json$/.test(name)){const row=await readRecord(join(control,name),name.slice(0,64)).catch(()=>null);if(row&&running(row.pid))return true;}return false;}
|
|
115
|
+
export async function runFront(requestPath:string):Promise<void>{
|
|
116
|
+
const spec=await smallJson(requestPath) as FrontRequest;if(spec?.schema!=='rig-review-front-request/1'||!uuid.test(spec.nonce??'')||!hex64.test(spec.fingerprint??'')||!Number.isSafeInteger(spec.port)||spec.port<1||spec.port>65535||!Number.isSafeInteger(spec.idleMs)||spec.idleMs<100)throw new Error('front: invalid request');
|
|
117
|
+
const control=await realpath(spec.control);if(dirname(await realpath(requestPath))!==control||spec.request!==requestPath||spec.record!==join(control,frontName(spec.port)+'.json'))throw new Error('front: invalid state locations');
|
|
118
|
+
if(await openFingerprint()!==spec.fingerprint)throw new Error('front: tool changed before startup');
|
|
119
|
+
let front:ReturnType<typeof startFront>;
|
|
120
|
+
try{front=startFront({control,port:spec.port,nonce:spec.nonce,fingerprint:spec.fingerprint});}catch(error){if((error as NodeJS.ErrnoException).code==='EADDRINUSE'||/in use/i.test(error instanceof Error?error.message:''))throw new Error(`FRONT_PORT_BUSY: 127.0.0.1:${spec.port}`);throw error;}
|
|
121
|
+
const record:FrontRecord={schema:'rig-review-front/1',origin:front.origin,port:spec.port,nonce:spec.nonce,fingerprint:spec.fingerprint,pid:process.pid},tmp=spec.record+'.'+spec.nonce+'.tmp';await writeFile(tmp,JSON.stringify(record),{mode:0o600,flag:'wx'});await rename(tmp,spec.record);
|
|
122
|
+
await unlink(requestPath).catch(e=>{if(e.code!=='ENOENT')throw e;});
|
|
123
|
+
let stopped=false;const stop=async()=>{if(stopped)return;stopped=true;clearInterval(timer);front.stop();const current=await readFront(spec.record,spec.port).catch(()=>null);if(current?.nonce===spec.nonce)await unlink(spec.record).catch(()=>{});process.exit(0);};
|
|
124
|
+
// Idle exit only once no request arrived for idleMs and no live worker record remains.
|
|
125
|
+
const timer=setInterval(()=>{if(front.idleMs()>=spec.idleMs&&front.active()===0)void liveInstances(control).then(live=>{if(!live)void stop();}).catch(()=>{});},Math.min(1000,spec.idleMs));
|
|
126
|
+
process.on('SIGTERM',()=>{void stop();});process.on('SIGINT',()=>{void stop();});
|
|
127
|
+
}
|
package/src/manifest.ts
CHANGED
|
@@ -103,6 +103,10 @@ export async function loadReview(input: string): Promise<ReviewPackage> {
|
|
|
103
103
|
return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog};
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** Same text for every command; `root` lets the open launcher name the refused input. */
|
|
107
|
+
export class UnrecognizedOutputError extends Error {
|
|
108
|
+
constructor(readonly root:string){super('Output folder: no recognized skeleton.*, build/skeleton.model.json, motion.json, report.json, source video, pose sequence or contact strip');}
|
|
109
|
+
}
|
|
106
110
|
/** An adapter over an existing output directory; never writes a manifest there. */
|
|
107
111
|
async function folderManifest(root:string):Promise<ReviewManifest>{
|
|
108
112
|
const entries=(await readdir(root,{withFileTypes:true})).filter(v=>v.isFile()||v.isSymbolicLink()).map(v=>v.name).sort();
|
|
@@ -121,7 +125,7 @@ async function folderManifest(root:string):Promise<ReviewManifest>{
|
|
|
121
125
|
}
|
|
122
126
|
const name=basename(root);const packageId=name.replace(/[^a-zA-Z0-9._-]+/g,'-').replace(/^[^a-zA-Z0-9]+/,'').slice(0,128)||'output-bundle';
|
|
123
127
|
const authoring=entries.filter(n=>n==='motion.json'||n==='report.json'||(/^skeleton\./i.test(n)&&n!=='skeleton.model.json')).map(path=>({label:path+' · read only',path}));
|
|
124
|
-
if(!references.length&&!authoring.length&&!model)throw new
|
|
128
|
+
if(!references.length&&!authoring.length&&!model)throw new UnrecognizedOutputError(root);
|
|
125
129
|
return parseManifest({schema:'rig-review/1',id:packageId,title:name,revision:'folder-adapter/1',model,motion:has('motion.json')?'motion.json':undefined,references,authoring,checkpoints:references.map(r=>({id:r.id,title:'Inspect '+r.label,reference:r.id,workspace:'motion'}))});
|
|
126
130
|
}
|
|
127
131
|
|
package/src/open-protocol.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Fixed-instance handshake and enforced landing binding; no filesystem control API. */
|
|
2
|
+
import {timingSafeEqual} from 'node:crypto';
|
|
2
3
|
export interface OpenIdentity {key:string;contentHash:string;nonce:string;fingerprint:string}
|
|
3
4
|
export function openGate(req:Request,identity:OpenIdentity|undefined):Response|null{
|
|
4
5
|
if(!identity)return null;const url=new URL(req.url);
|
|
@@ -12,3 +13,19 @@ export function openGate(req:Request,identity:OpenIdentity|undefined):Response|n
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function staleOpenStatus(identity:OpenIdentity):Response{return Response.json({schema:'rig-review-open-status/1',...identity,ready:false,error:'Inputs changed; open a new review binding'},{status:409,headers:{'Cache-Control':'no-store'}});}
|
|
16
|
+
/** Header names shared by the worker, the managed front and the page. */
|
|
17
|
+
export const capabilityHeader='X-Review-Capability',openNonceHeader='X-Review-Open-Nonce',openKeyHeader='X-Review-Open-Key';
|
|
18
|
+
export const capabilityPattern=/^[a-f0-9]{64}$/;
|
|
19
|
+
export function sameSecret(actual:string|null,expected:string):boolean{const a=Buffer.from(actual??''),b=Buffer.from(expected);return a.length===b.length&&timingSafeEqual(a,b);}
|
|
20
|
+
/** Every non-GET request needs this instance's page capability, compared in constant time before any
|
|
21
|
+
* Origin check. GET /api/access only says whether one is required; no endpoint ever returns it. */
|
|
22
|
+
export function accessGate(req:Request,capability:string|undefined):Response|null{
|
|
23
|
+
const url=new URL(req.url),headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'};
|
|
24
|
+
if(url.pathname==='/api/access')return req.method==='GET'||req.method==='HEAD'?Response.json({schema:'rig-review-access/1',capabilityRequired:!!capability},{headers}):Response.json({error:'Method not allowed'},{status:405,headers});
|
|
25
|
+
if(!capability||req.method==='GET'||req.method==='HEAD')return null;
|
|
26
|
+
return sameSecret(req.headers.get(capabilityHeader),capability)?null:Response.json({error:'REVIEW_CAPABILITY_REQUIRED: reopen with rig-review open to edit'},{status:403,headers});
|
|
27
|
+
}
|
|
28
|
+
/** The front forwards the open record's nonce on every request; a replaced worker refuses it. */
|
|
29
|
+
export function openNonceGate(req:Request,identity:OpenIdentity|undefined):Response|null{const sent=req.headers.get(openNonceHeader);if(sent===null||!identity)return null;return sameSecret(sent,identity.nonce)?null:Response.json({error:'OPEN_INSTANCE_REPLACED'},{status:409,headers:{'Cache-Control':'no-store'}});}
|
|
30
|
+
/** Proof for the front that this exact worker answered, not whatever now holds the port. */
|
|
31
|
+
export function stampOpen(req:Request,response:Response,identity:OpenIdentity|undefined):Response{if(identity&&sameSecret(req.headers.get(openNonceHeader),identity.nonce))response.headers.set(openKeyHeader,identity.key);return response;}
|
package/src/open.ts
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import {parseControlCommand,type ControlCommand,type ControlReceipt} from './control.ts';
|
|
2
2
|
/** One fixed-package worker per exact binding. There is no general daemon API. */
|
|
3
|
-
import {createHash,randomUUID} from 'node:crypto';
|
|
3
|
+
import {createHash,randomBytes,randomUUID} from 'node:crypto';
|
|
4
4
|
import {spawn} from 'node:child_process';
|
|
5
5
|
import {homedir} from 'node:os';
|
|
6
|
-
import {mkdir,readFile,readdir,writeFile,rename,unlink,rm,rmdir,realpath,lstat,open as openFile} from 'node:fs/promises';
|
|
6
|
+
import {mkdir,readFile,readdir,writeFile,rename,unlink,rm,rmdir,realpath,lstat,stat,open as openFile} from 'node:fs/promises';
|
|
7
7
|
import {constants} from 'node:fs';
|
|
8
8
|
import {basename,dirname,join,relative,resolve,isAbsolute} from 'node:path';
|
|
9
9
|
import {fileURLToPath} from 'node:url';
|
|
10
10
|
import {loadReferencedReview,selectedReference,assertReviewUnchanged} from './reference.ts';
|
|
11
|
-
import {loadReview,type ReviewPackage} from './manifest.ts';
|
|
11
|
+
import {loadReview,UnrecognizedOutputError,type ReviewPackage} from './manifest.ts';
|
|
12
12
|
import {loadCompare,assertCompareUnchanged,type ComparePackage} from './compare.ts';
|
|
13
13
|
import {startServer} from './server.ts';
|
|
14
14
|
import {startCompareServer} from './compare-server.ts';
|
|
15
|
-
import {selectAnimation,type AnimationSelection} from './animation-selection.ts';
|
|
16
|
-
import type
|
|
15
|
+
import {selectAnimation,AnimationSelectionError,type AnimationSelection} from './animation-selection.ts';
|
|
16
|
+
import {capabilityHeader,capabilityPattern,type OpenIdentity} from './open-protocol.ts';
|
|
17
|
+
import {ensureFront,frontPort} from './front.ts';
|
|
17
18
|
import {playPackagePath} from './versions.ts';
|
|
18
|
-
|
|
19
|
+
/** `direct` chooses how the page is reached (own port instead of the managed front); it is not part of the instance identity. */
|
|
20
|
+
export interface OpenInputs {input:string;compare?:string;reference?:string;animation?:string;control?:boolean;direct?:boolean}
|
|
19
21
|
export type OpenPackage=({mode:'single';p:ReviewPackage;inputs:OpenInputs;contentHash:string}|{mode:'compare';p:ComparePackage;inputs:OpenInputs;contentHash:string})&{selection:AnimationSelection|null};
|
|
20
|
-
|
|
22
|
+
/** Private 0600 record. `capability` is the 256-bit page write capability, handed out only in the printed `#cap=` fragment. */
|
|
23
|
+
export interface RecordFile extends OpenIdentity {schema:'rig-review-open-instance/1';pid:number;origin:string;mode:'single'|'compare';capability:string;controlToken?:string}
|
|
21
24
|
interface WorkerRequest {schema:'rig-review-open-request/1';inputs:OpenInputs;identity:OpenIdentity;stateDir:string;registry:string;request:string;idleMs:number;controlToken?:string}
|
|
22
25
|
const appRoot=fileURLToPath(new URL('..',import.meta.url));
|
|
23
26
|
const digest=(value:unknown)=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
@@ -40,61 +43,91 @@ export async function openFingerprint():Promise<string>{
|
|
|
40
43
|
hash.update('@rig-c/play/package.json\0');hash.update(await readFile(playPackagePath()));return hash.digest('hex');
|
|
41
44
|
}
|
|
42
45
|
export function openKey(p:OpenPackage,fingerprint:string):string {return digest({fingerprint,installation:appRoot,mode:p.mode,inputs:p.inputs,content:p.contentHash,identity:p.mode==='single'?p.p.reviewedSource:p.p.response.binding});}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
function
|
|
46
|
-
async function
|
|
46
|
+
/** Handoff URL: the managed front's `/instances/<key>/` path (or the worker's own origin when direct). The
|
|
47
|
+
* capability rides in the fragment, which browsers never send to a server; the page stores and strips it. */
|
|
48
|
+
export function openUrl(record:Pick<RecordFile,'origin'|'key'|'contentHash'|'capability'>,front?:string):string {const url=new URL(front?`${front}/instances/${record.key}/`:record.origin);url.searchParams.set('input',record.contentHash);url.searchParams.set('handoff',record.key);url.hash='cap='+record.capability;return url.href;}
|
|
49
|
+
export async function smallJson(file:string):Promise<unknown>{const handle=await openFile(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);try{const before=await handle.stat();if(!before.isFile()||before.size>65536)throw new Error('open state: bounded regular file required');const buffer=Buffer.alloc(before.size+1);let size=0;while(size<buffer.length){const next=await handle.read(buffer,size,buffer.length-size,size);if(!next.bytesRead)break;size+=next.bytesRead;}const after=await handle.stat();if(size!==before.size||before.size!==after.size||before.mtimeMs!==after.mtimeMs)throw new Error('open state: changed while reading');return JSON.parse(buffer.subarray(0,size).toString('utf8'));}finally{await handle.close();}}
|
|
50
|
+
function recordValue(value:unknown,key:string):RecordFile|null {if(!value||typeof value!=='object')return null;const v=value as Partial<RecordFile>;return v.schema==='rig-review-open-instance/1'&&v.key===key&&/^[a-f0-9]{64}$/.test(v.contentHash??'')&&/^[a-f0-9]{64}$/.test(v.fingerprint??'')&&typeof v.nonce==='string'&&/^[a-f0-9-]{36}$/.test(v.nonce)&&typeof v.origin==='string'&&/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(v.origin)&&Number(v.origin.slice(v.origin.lastIndexOf(':')+1))<=65535&&Number.isSafeInteger(v.pid)&&v.pid!>0&&(v.mode==='single'||v.mode==='compare')&&capabilityPattern.test(v.capability??'')?v as RecordFile:null;}
|
|
51
|
+
export async function readRecord(file:string,key:string):Promise<RecordFile|null>{try{return recordValue(await smallJson(file),key);}catch(e){if(['ENOENT','ELOOP'].includes((e as NodeJS.ErrnoException).code??'')||e instanceof SyntaxError)return null;throw e;}}
|
|
47
52
|
/** Hard byte ceiling and body deadline, including stalled or chunked responses. */
|
|
48
53
|
export async function readOpenStatus(response:Response,timeoutMs=1500):Promise<unknown>{
|
|
49
54
|
if(!response.body)throw new Error('open status: body required');const reader=response.body.getReader();let timer:ReturnType<typeof setTimeout>|undefined;
|
|
50
55
|
try{return await Promise.race([(async()=>{const chunks:Uint8Array[]=[];let size=0;while(true){const next=await reader.read();if(next.done)break;size+=next.value.length;if(size>65536)throw new Error('open status: exceeds 64 KiB');chunks.push(next.value);}const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}return JSON.parse(new TextDecoder().decode(bytes));})(),new Promise<never>((_,reject)=>{timer=setTimeout(()=>reject(new Error('open status: body timeout')),timeoutMs);})]);}
|
|
51
56
|
finally{if(timer)clearTimeout(timer);void reader.cancel().catch(()=>{});try{reader.releaseLock();}catch{}}
|
|
52
57
|
}
|
|
53
|
-
async function alive(record:RecordFile):Promise<boolean>{try{const response=await fetch(record.origin+'/api/open-status',{headers:{authorization:`Bearer ${record.nonce}`},redirect:'error',signal:AbortSignal.timeout(1500)});if(!response.ok){void response.body?.cancel();return false;}const v=await readOpenStatus(response) as OpenIdentity&{schema:string};return v.schema==='rig-review-open-status/1'&&v.key===record.key&&v.contentHash===record.contentHash&&v.nonce===record.nonce&&v.fingerprint===record.fingerprint;}catch{return false;}}
|
|
58
|
+
export async function alive(record:RecordFile):Promise<boolean>{try{const response=await fetch(record.origin+'/api/open-status',{headers:{authorization:`Bearer ${record.nonce}`},redirect:'error',signal:AbortSignal.timeout(1500)});if(!response.ok){void response.body?.cancel();return false;}const v=await readOpenStatus(response) as OpenIdentity&{schema:string};return v.schema==='rig-review-open-status/1'&&v.key===record.key&&v.contentHash===record.contentHash&&v.nonce===record.nonce&&v.fingerprint===record.fingerprint;}catch{return false;}}
|
|
54
59
|
|
|
55
|
-
function running(pid:number):boolean{try{process.kill(pid,0);return true;}catch(e){return (e as NodeJS.ErrnoException).code==='EPERM';}}
|
|
60
|
+
export function running(pid:number):boolean{try{process.kill(pid,0);return true;}catch(e){return (e as NodeJS.ErrnoException).code==='EPERM';}}
|
|
56
61
|
async function claimOwner(claim:string):Promise<{file:string;pid:number;nonce:string}|null>{
|
|
57
62
|
const info=await lstat(claim);if(info.isSymbolicLink()||!info.isDirectory())throw new Error('open.state: claim must be an owned directory');const names=await readdir(claim);if(!names.length)return null;if(names.length!==1||!/^owner(?:\.[a-f0-9-]{36})?\.json$/.test(names[0]!))throw new Error('open.state: invalid startup claim');const file=join(claim,names[0]!),owner=await smallJson(file) as {pid?:number;nonce?:string};if(!Number.isSafeInteger(owner.pid)||owner.pid!<=0||typeof owner.nonce!=='string'||!/^([a-f0-9-]{36})$/.test(owner.nonce)||names[0]!=='owner.json'&&names[0]!==`owner.${owner.nonce}.json`)throw new Error('open.state: invalid startup claimant');return {file,pid:owner.pid!,nonce:owner.nonce};
|
|
58
63
|
}
|
|
59
64
|
async function removeClaimFile(claim:string,owner:{file:string;nonce:string}){try{const current=await smallJson(owner.file) as {nonce?:string};if(current.nonce!==owner.nonce)return;await unlink(owner.file);await rmdir(claim);}catch(e){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes((e as NodeJS.ErrnoException).code??''))throw e;}}
|
|
60
|
-
async function releaseClaim(claim:string,nonce:string){await removeClaimFile(claim,{file:join(claim,`owner.${nonce}.json`),nonce});}
|
|
65
|
+
export async function releaseClaim(claim:string,nonce:string){await removeClaimFile(claim,{file:join(claim,`owner.${nonce}.json`),nonce});}
|
|
61
66
|
async function canonicalDestination(path:string):Promise<string>{try{return await realpath(path);}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;const parent=dirname(path);if(parent===path)throw e;return join(await canonicalDestination(parent),basename(path));}}
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
/** Single-owner startup: publish a populated claim directory atomically (no live owner ever exposes an empty
|
|
68
|
+
* claim; unique owner filenames make stale cleanup safe after replacement). A ready reuse wins while waiting. */
|
|
69
|
+
export async function acquireClaim<T>(control:string,name:string,nonce:string,reuse:()=>Promise<T|null>,deadline:number,busy:string):Promise<{row:T}|{claim:string}>{
|
|
70
|
+
const claim=join(control,name+'.claim'),candidate=join(control,`${name}.${nonce}.claiming`);await mkdir(candidate,{mode:0o700});await writeFile(join(candidate,`owner.${nonce}.json`),JSON.stringify({pid:process.pid,nonce}),{mode:0o600,flag:'wx'});
|
|
71
|
+
try{while(true){try{try{await lstat(claim);const occupied=new Error('claim exists') as NodeJS.ErrnoException;occupied.code='EEXIST';throw occupied;}catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}await rename(candidate,claim);return {claim};}
|
|
72
|
+
catch(e){if(!['EEXIST','ENOTEMPTY'].includes((e as NodeJS.ErrnoException).code??''))throw e;const row=await reuse();if(row){await rm(candidate,{recursive:true});return {row};}
|
|
73
|
+
try{const before=await lstat(claim),owner=await claimOwner(claim);if(owner&&!running(owner.pid)){const again=await claimOwner(claim);if(again?.nonce===owner.nonce&&(await lstat(claim)).ino===before.ino)await removeClaimFile(claim,owner);}else if(!owner&&Date.now()-before.mtimeMs>=250&&(await lstat(claim)).ino===before.ino){try{await rmdir(claim);}catch(error){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes((error as NodeJS.ErrnoException).code??''))throw error;}}}
|
|
74
|
+
catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}if(performance.now()>deadline)throw new Error(busy);await sleep(30);}}}
|
|
75
|
+
catch(error){await rm(candidate,{recursive:true,force:true});throw error;}
|
|
76
|
+
}
|
|
77
|
+
/** Launcher contract (README "Launcher contract"): an `open` refusal's first line starts with a stable OPEN_* name.
|
|
78
|
+
* The shared loaders keep their check/feedback text; only this path renames them. */
|
|
79
|
+
async function assertOpenInput(path:string):Promise<void>{
|
|
80
|
+
let info;try{info=await stat(path);}catch(e){if(['ENOENT','ENOTDIR','ELOOP','ENAMETOOLONG'].includes((e as NodeJS.ErrnoException).code??''))throw new Error(`OPEN_INPUT_MISSING ${path}: no such path; expected an existing output directory or review.json`);throw e;}
|
|
81
|
+
if(!info.isDirectory()&&!info.isFile())throw new Error(`OPEN_INPUT_MISSING ${path}: found a non-regular file; expected an output directory or a review.json manifest`);
|
|
82
|
+
if(info.isFile()){try{JSON.parse(await readFile(path,'utf8'));}catch(e){throw new Error(`OPEN_INPUT_UNRECOGNIZED ${path}: not a JSON manifest; expected an output directory or a review.json manifest`,{cause:e});}}
|
|
83
|
+
}
|
|
84
|
+
async function openRefusal(error:unknown,inputs:OpenInputs):Promise<unknown>{
|
|
85
|
+
if(error instanceof UnrecognizedOutputError){const first=await realpath(resolve(inputs.input)).catch(()=>null),path=inputs.compare!==undefined&&error.root!==first?inputs.compare:inputs.input;return new Error(`OPEN_INPUT_UNRECOGNIZED ${path}: ${error.message}`,{cause:error});}
|
|
86
|
+
if(!(error instanceof AnimationSelectionError))return error;
|
|
87
|
+
const detail=(error.label==='a'||error.label==='b'?`side ${error.label}: `:'')+error.message.replace(/^open\.(?:[a-z]+\.)?animation: /,'');
|
|
88
|
+
const name=error.kind==='empty'?'OPEN_OPTION_INVALID --animation':error.kind==='unknown'?`OPEN_ANIMATION_UNKNOWN ${JSON.stringify(error.animation)}`:error.kind==='ambiguous'?'OPEN_ANIMATION_AMBIGUOUS':'OPEN_ANIMATION_UNVALIDATABLE';
|
|
89
|
+
return new Error(`${name}: ${detail}`,{cause:error});
|
|
90
|
+
}
|
|
91
|
+
/** Managed is the default: the printed URL goes through the one loopback front (127.0.0.1:4318, or
|
|
92
|
+
* RIG_REVIEW_FRONT_PORT) so browser storage keeps one origin across reopens. `direct` keeps the worker's own port. */
|
|
93
|
+
export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idleMs?:number;direct?:boolean;frontPort?:number;frontIdleMs?:number}={}):Promise<{url:string;reused:boolean;nonce:string;inputs:OpenInputs;selection:AnimationSelection|null;direct:boolean}>{
|
|
94
|
+
const direct=options.direct??inputs.direct??false,port=direct?0:frontPort(options.frontPort);
|
|
95
|
+
for(const path of [inputs.input,...(inputs.compare!==undefined?[inputs.compare]:[])])await assertOpenInput(path);
|
|
96
|
+
if(inputs.compare!==undefined&&!inputs.reference)throw new Error('OPEN_REFERENCE_REQUIRED: compare: explicit --reference required');
|
|
97
|
+
let p:OpenPackage;try{p=await loadOpenPackage(inputs);}catch(error){throw await openRefusal(error,inputs);}
|
|
98
|
+
const fingerprint=await openFingerprint(),key=openKey(p,fingerprint);
|
|
64
99
|
const stateDir=await canonicalDestination(resolve(options.stateDir??defaultStateDir())),roots=p.mode==='single'?[p.p.root]:[p.p.a.root,p.p.b.root];
|
|
65
100
|
for(const root of roots){const rel=relative(root,stateDir);if(!rel||rel!=='..'&&!rel.startsWith('..'+(process.platform==='win32'?'\\':'/'))&&!isAbsolute(rel))throw new Error('open.state: must be outside input packages');}
|
|
66
101
|
const control=join(stateDir,'open');await mkdir(control,{recursive:true,mode:0o700});const canonicalControl=await realpath(control);if((await lstat(control)).isSymbolicLink())throw new Error('open.state: control directory cannot be a symlink');
|
|
102
|
+
const worker=await ensureWorker(p,key,fingerprint,stateDir,canonicalControl,options.idleMs??30*60*1000);
|
|
103
|
+
const url=direct?openUrl(worker.row):openUrl(worker.row,await ensureFront({control:canonicalControl,port,fingerprint,idleMs:options.frontIdleMs}));
|
|
104
|
+
return {url,reused:worker.reused,nonce:worker.row.nonce,inputs:p.inputs,selection:p.selection,direct};
|
|
105
|
+
}
|
|
106
|
+
async function ensureWorker(p:OpenPackage,key:string,fingerprint:string,stateDir:string,canonicalControl:string,idleMs:number):Promise<{row:RecordFile;reused:boolean}>{
|
|
67
107
|
const registry=join(canonicalControl,key+'.json'),claim=join(canonicalControl,key+'.claim'),nonce=randomUUID(),deadline=performance.now()+20000;
|
|
68
|
-
const reuse=async()=>{const row=await readRecord(registry,key);return row&&row.fingerprint===fingerprint&&row.contentHash===p.contentHash&&await alive(row)?row:null;};
|
|
69
|
-
const ready=await reuse();if(ready)return {
|
|
70
|
-
|
|
71
|
-
// empty claim. Unique owner filenames make stale cleanup safe after replacement.
|
|
72
|
-
const candidate=join(canonicalControl,`${key}.${nonce}.claiming`);await mkdir(candidate,{mode:0o700});await writeFile(join(candidate,`owner.${nonce}.json`),JSON.stringify({pid:process.pid,nonce}),{mode:0o600,flag:'wx'});
|
|
73
|
-
let claimed=false;
|
|
74
|
-
try{while(!claimed){try{try{await lstat(claim);const occupied=new Error('claim exists') as NodeJS.ErrnoException;occupied.code='EEXIST';throw occupied;}catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}await rename(candidate,claim);claimed=true;}
|
|
75
|
-
catch(e){if(!['EEXIST','ENOTEMPTY'].includes((e as NodeJS.ErrnoException).code??''))throw e;const row=await reuse();if(row){await rm(candidate,{recursive:true});return {url:openUrl(row),reused:true,nonce:row.nonce,inputs:p.inputs,selection:p.selection};}
|
|
76
|
-
try{const before=await lstat(claim),owner=await claimOwner(claim);if(owner&&!running(owner.pid)){const again=await claimOwner(claim);if(again?.nonce===owner.nonce&&(await lstat(claim)).ino===before.ino)await removeClaimFile(claim,owner);}else if(!owner&&Date.now()-before.mtimeMs>=250&&(await lstat(claim)).ino===before.ino){try{await rmdir(claim);}catch(error){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes((error as NodeJS.ErrnoException).code??''))throw error;}}}
|
|
77
|
-
catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}if(performance.now()>deadline)throw new Error('OPEN_BUSY: instance startup claim did not become ready');await sleep(30);}}}
|
|
78
|
-
catch(error){await rm(candidate,{recursive:true,force:true});throw error;}
|
|
108
|
+
const reuse=async()=>{const row=await readRecord(registry,key);return row&&row.fingerprint===fingerprint&&row.contentHash===p.contentHash&&row.mode===p.mode&&await alive(row)?row:null;};
|
|
109
|
+
const ready=await reuse();if(ready)return {row:ready,reused:true};
|
|
110
|
+
const claimed=await acquireClaim(canonicalControl,key,nonce,reuse,deadline,'OPEN_BUSY: instance startup claim did not become ready');if('row' in claimed)return {row:claimed.row,reused:true};
|
|
79
111
|
let request:string|undefined,spawned:ReturnType<typeof spawn>|undefined,handedOff=false;
|
|
80
112
|
try{
|
|
81
|
-
const row=await reuse();if(row)return {
|
|
113
|
+
const row=await reuse();if(row)return {row,reused:true};
|
|
82
114
|
const identity:OpenIdentity={key,contentHash:p.contentHash,nonce,fingerprint};request=join(canonicalControl,`${key}.${nonce}.request.json`);const log=join(canonicalControl,`${key}.${nonce}.log`);
|
|
83
|
-
const spec:WorkerRequest={schema:'rig-review-open-request/1',inputs:p.inputs,identity,stateDir,registry,request,idleMs
|
|
115
|
+
const spec:WorkerRequest={schema:'rig-review-open-request/1',inputs:p.inputs,identity,stateDir,registry,request,idleMs,...(p.inputs.control?{controlToken:randomUUID()}:{} )};await writeFile(request,JSON.stringify(spec),{mode:0o600,flag:'wx'});
|
|
84
116
|
const logHandle=await openFile(log,'a',0o600);let child;try{child=spawn(process.execPath,[join(appRoot,'src/open-worker.ts'),request],{cwd:canonicalControl,detached:true,stdio:['ignore',logHandle.fd,logHandle.fd],env:process.env});spawned=child;child.unref();}finally{await logHandle.close();}
|
|
85
117
|
let exited:string|undefined;child.on('error',error=>{exited=error.message;});child.on('exit',(code,signal)=>{exited=`exit ${code} ${signal??''}`;});
|
|
86
|
-
while(performance.now()<deadline){const record=await reuse();if(record){handedOff=true;return {
|
|
118
|
+
while(performance.now()<deadline){const record=await reuse();if(record){handedOff=true;return {row:record,reused:false};}if(exited)throw new Error(`OPEN_START: ${exited}; worker log ${log}`);await sleep(30);}throw new Error(`OPEN_TIMEOUT: worker did not become ready; log ${log}`);
|
|
87
119
|
}finally{if(spawned&&!handedOff&&spawned.exitCode===null&&spawned.signalCode===null)spawned.kill('SIGTERM');await releaseClaim(claim,nonce);if(request)await unlink(request).catch(e=>{if(e.code!=='ENOENT')throw e;});}
|
|
88
120
|
}
|
|
89
121
|
export async function runOpenWorker(requestPath:string):Promise<void>{
|
|
90
|
-
const raw=await smallJson(requestPath) as WorkerRequest;if(raw.schema!=='rig-review-open-request/1'||!raw.identity||!recordValue({schema:'rig-review-open-instance/1',...raw.identity,pid:process.pid,origin:'http://127.0.0.1:1'},raw.identity.key))throw new Error('open worker: invalid request');
|
|
122
|
+
const raw=await smallJson(requestPath) as WorkerRequest;if(raw.schema!=='rig-review-open-request/1'||!raw.identity||!recordValue({schema:'rig-review-open-instance/1',...raw.identity,pid:process.pid,origin:'http://127.0.0.1:1',mode:'single',capability:'0'.repeat(64)},raw.identity.key))throw new Error('open worker: invalid request');
|
|
91
123
|
const spec=raw,control=await realpath(join(spec.stateDir,'open'));if(dirname(await realpath(requestPath))!==control||spec.request!==requestPath||spec.registry!==join(control,spec.identity.key+'.json')||!Number.isSafeInteger(spec.idleMs)||spec.idleMs<100)throw new Error('open worker: invalid state locations or idle limit');
|
|
92
124
|
const p=await loadOpenPackage(spec.inputs),fingerprint=await openFingerprint();if(fingerprint!==spec.identity.fingerprint||openKey(p,fingerprint)!==spec.identity.key||p.contentHash!==spec.identity.contentHash)throw new Error('open worker: inputs changed before startup');
|
|
93
125
|
if(spec.inputs.control&&(!spec.controlToken||!/^[a-f0-9-]{36}$/.test(spec.controlToken)))throw new Error('open control: invalid private capability');
|
|
94
|
-
|
|
126
|
+
// The worker mints its own page capability; it lives only in this process and the private record.
|
|
127
|
+
const capability=randomBytes(32).toString('hex');let last=performance.now();const options={port:0,stateDir:spec.stateDir,open:spec.identity,capability,activity:()=>{last=performance.now();},...(spec.inputs.control?{control:{token:spec.controlToken!,instanceKey:spec.identity.key}}:{})};
|
|
95
128
|
const server=p.mode==='single'?await startServer(p.p,{...options,initialAnimation:p.selection?.name}):await startCompareServer(p.p,options);
|
|
96
129
|
const check=()=>p.mode==='single'?assertReviewUnchanged(p.p):assertCompareUnchanged(p.p);
|
|
97
|
-
await check();const record:RecordFile={schema:'rig-review-open-instance/1',...spec.identity,pid:process.pid,origin:`http://127.0.0.1:${server.port}
|
|
130
|
+
await check();const record:RecordFile={schema:'rig-review-open-instance/1',...spec.identity,pid:process.pid,origin:`http://127.0.0.1:${server.port}`,mode:p.mode,capability,...(spec.inputs.control?{controlToken:spec.controlToken}:{})};const tmp=spec.registry+'.'+spec.identity.nonce+'.tmp';await writeFile(tmp,JSON.stringify(record),{mode:0o600,flag:'wx'});await rename(tmp,spec.registry);
|
|
98
131
|
await unlink(requestPath).catch(e=>{if(e.code!=='ENOENT')throw e;});
|
|
99
132
|
let stopped=false;const stop=async()=>{if(stopped)return;stopped=true;server.stop(true);clearInterval(timer);const current=await readRecord(spec.registry,spec.identity.key);if(current?.nonce===spec.identity.nonce)await unlink(spec.registry).catch(()=>{});};
|
|
100
133
|
const timer=setInterval(()=>{if(performance.now()-last>=spec.idleMs)void stop();},Math.min(1000,spec.idleMs));process.on('SIGTERM',()=>{void stop();});process.on('SIGINT',()=>{void stop();});
|
|
@@ -109,7 +142,7 @@ export async function controlReview(inputs:OpenInputs,commandOptions:{pageId?:st
|
|
|
109
142
|
const file=join(controlDir,key+'.json');try{const info=await lstat(file);if(!info.isFile()||(info.mode&0o077)!==0)throw new Error('CONTROL_STATE: owner-only regular capability record required');}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')throw new Error('CONTROL_INSTANCE_UNAVAILABLE: open the exact inputs once with --control');throw error;}
|
|
110
143
|
const record=await readRecord(file,key);
|
|
111
144
|
if(!record||!record.controlToken||!await alive(record))throw new Error('CONTROL_INSTANCE_UNAVAILABLE: open the exact inputs once with --control');
|
|
112
|
-
const headers={Authorization:'Bearer '+record.controlToken,Origin:record.origin,'Content-Type':'application/json'};
|
|
145
|
+
const headers={Authorization:'Bearer '+record.controlToken,Origin:record.origin,'Content-Type':'application/json',[capabilityHeader]:record.capability};
|
|
113
146
|
const get=async(path:string,body?:unknown)=>{const response=await fetch(record.origin+path,{method:body===undefined?'GET':'POST',headers,...(body===undefined?{}:{body:JSON.stringify(body)}),redirect:'error',signal:AbortSignal.timeout(5000)});const value=await readOpenStatus(response,5000);if(!response.ok)throw new Error((value as {error?:string}).error??'CONTROL_REQUEST_FAILED');return value;};
|
|
114
147
|
const state=await get('/api/control') as {sourceHash:string;instanceKey:string;pages:{pageId:string;revision:number}[]};
|
|
115
148
|
if(state.sourceHash!==p.contentHash||state.instanceKey!==key)throw new Error('CONTROL_STALE_BINDING');
|
package/src/server.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { appRoot, buildWeb } from './build.ts';
|
|
|
4
4
|
import { packagePath, safeFile, type ReviewPackage } from './manifest.ts';
|
|
5
5
|
import { exportReviewStrip, stripJson } from './strip-export.ts';
|
|
6
6
|
import {assertReviewUnchanged,referenceBinding,selectedReference,safeSelectedReference,serveReference} from './reference.ts';
|
|
7
|
-
import {openGate,staleOpenStatus,type OpenIdentity} from './open-protocol.ts';
|
|
7
|
+
import {openGate,staleOpenStatus,accessGate,openNonceGate,stampOpen,type OpenIdentity} from './open-protocol.ts';
|
|
8
8
|
import { readSession, saveSession } from './session.ts';
|
|
9
9
|
const headers={ 'X-Content-Type-Options':'nosniff','Cache-Control':'no-store','Content-Security-Policy':"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self'; connect-src 'self'; font-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",'Referrer-Policy':'no-referrer'};
|
|
10
10
|
function json(data:unknown,status=200){return Response.json(data,{status,headers});}
|
|
@@ -15,14 +15,15 @@ async function bodyJson(req:Request):Promise<unknown>{
|
|
|
15
15
|
while(true){const {value,done}=await reader.read();if(done)break;bytes+=value.length;if(bytes>1024*1024){await reader.cancel();throw new Error('Session payload exceeds 1 MiB');}chunks.push(value);}
|
|
16
16
|
const full=new Uint8Array(bytes);let offset=0;for(const chunk of chunks){full.set(chunk,offset);offset+=chunk.length;}return JSON.parse(new TextDecoder().decode(full));
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
/** `capability` (open workers and foreground CLI serving) gates every non-GET request; tests may omit it. */
|
|
19
|
+
export async function startServer(p:ReviewPackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;initialAnimation?:string;activity?:()=>void;control?:ControlIdentity;capability?:string}={}){
|
|
19
20
|
const bundle=await buildWeb();
|
|
20
21
|
const unchanged=()=>assertReviewUnchanged(p);
|
|
21
22
|
const control=options.control?createControlServer({identity:options.control,sourceHash:p.sourceHash,mode:'single',unchanged,activity:options.activity}):null;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const url=new URL(req.url);const origin=`http://127.0.0.1:${server.port}`;
|
|
23
|
+
const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try {
|
|
24
|
+
const url=new URL(req.url);const origin=`http://127.0.0.1:${port}`;
|
|
25
25
|
if(url.origin!==origin)return json({error:'Loopback host required'},403);
|
|
26
|
+
const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
|
|
26
27
|
const controlled=await control?.(req,origin);if(controlled)return controlled;
|
|
27
28
|
options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await unchanged();}catch{return staleOpenStatus(options.open!);}}return gate;}
|
|
28
29
|
if(req.method==='PUT'&&url.pathname==='/api/session'){
|
|
@@ -56,6 +57,6 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
56
57
|
if(url.pathname==='/'||url.pathname==='/index.html')return new Response(req.method==='HEAD'?null:await readFile(appRoot+'web/index.html'),{headers:{...headers,'Content-Type':'text/html; charset=utf-8'}});
|
|
57
58
|
if(url.pathname==='/styles.css')return new Response(req.method==='HEAD'?null:await readFile(appRoot+'web/styles.css'),{headers:{...headers,'Content-Type':'text/css'}});
|
|
58
59
|
return json({error:'Not found'},404);
|
|
59
|
-
}catch(e){const message=e instanceof Error?e.message:'Request failed';return json({error:message},message.includes('inputs changed')?409:400);}}
|
|
60
|
-
});
|
|
60
|
+
}catch(e){const message=e instanceof Error?e.message:'Request failed';return json({error:message},message.includes('inputs changed')?409:400);}};
|
|
61
|
+
return Bun.serve({hostname:'127.0.0.1',port:options.port??4317,async fetch(req,server){return stampOpen(req,await handle(req,server.port),options.open);}});
|
|
61
62
|
}
|
package/src/strip-render.ts
CHANGED
|
@@ -31,7 +31,7 @@ function command(binary:string,args:string[],kind:string):Promise<Buffer>{
|
|
|
31
31
|
export async function referenceFramePng(path:string,index:number,binary:string,width:number,height:number):Promise<Buffer>{
|
|
32
32
|
// Downsize before native image decode; output cells must not retain hundreds
|
|
33
33
|
// of full-resolution source rasters merely because the video was 4K.
|
|
34
|
-
return command(binary,['-v','error','-threads','1','-filter_threads','1','-i',path,'-map','0:v:0','-vf',`select=eq(n\\,${index}),scale=${width}:${height}:force_original_aspect_ratio=decrease`,'-
|
|
34
|
+
return command(binary,['-v','error','-threads','1','-filter_threads','1','-i',path,'-map','0:v:0','-vf',`select=eq(n\\,${index}),scale=${width}:${height}:force_original_aspect_ratio=decrease`,'-fps_mode','passthrough','-frames:v','1','-f','image2pipe','-c:v','png','-threads','1','pipe:1'],'FFMPEG');
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** Only DOM ownership and canvas/image IO are adapted; all drawing remains native/player code. */
|
package/web/camera-view.ts
CHANGED
|
@@ -32,3 +32,13 @@ export function zoomSignature(value:CameraSignature,factor:number):CameraSignatu
|
|
|
32
32
|
export function dragSignature(value:CameraSignature,frame:CameraFrame,dx:number,dy:number):CameraSignature {
|
|
33
33
|
return {zoom:value.zoom,pan:{x:value.pan.x+dx/frame.width,y:value.pan.y+dy/frame.height}};
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/** Fit a selected conservative world box using the existing whole-rig camera
|
|
37
|
+
* reference and normalized sync contract. The established zoom range still applies. */
|
|
38
|
+
export function boundsFitSignature(box:{x:number;y:number;width:number;height:number},frame:CameraFrame,marginX=0,marginY=0):CameraSignature {
|
|
39
|
+
if(![box.x,box.y,box.width,box.height].every(Number.isFinite)||box.width<=0||box.height<=0)throw new Error('PART_FIT_UNAVAILABLE: positive finite drawn bounds required');
|
|
40
|
+
const scale=Math.min((frame.width-marginX)/box.width,(frame.height-marginY)/box.height);
|
|
41
|
+
if(!Number.isFinite(scale)||scale<=0)throw new Error('PART_FIT_UNAVAILABLE: viewport has no usable extent');
|
|
42
|
+
const camera={zoom:clampZoom(scale/frame.baseScale),center:{x:box.x+box.width/2,y:box.y+box.height/2}};
|
|
43
|
+
assertCameraView(camera,frame);return cameraSignature(camera,frame);
|
|
44
|
+
}
|
package/web/compare.css
CHANGED
|
@@ -94,3 +94,47 @@ body{display:grid;grid-template-rows:auto minmax(0,1fr) auto}.layout{grid-templa
|
|
|
94
94
|
.numeric-view{font:11px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%;padding:8px;background:#14232c;color:#c9dfe7;border-radius:5px}.view-link-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px}.draft-notice{flex:none;font-size:11px;padding:0 12px;color:#e0c58e}.draft-notice:has(#draft-state:empty){display:none}.draft-notice #draft-recovery{display:inline-flex;gap:8px;margin-left:8px;flex-wrap:wrap}.draft-notice button{font-size:11px;padding:2px 6px}[data-theme=light] .numeric-view{background:#e1eaee;color:#264653}[data-theme=light] .draft-notice{color:#80521a}[data-theme=light] .outcome legend,[data-theme=light] .brand span,[data-theme=light] .toolbar{color:#496774}
|
|
95
95
|
|
|
96
96
|
.camera-sync{display:inline-flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}.camera-sync input[type=checkbox]{width:auto;flex:none;margin:0}
|
|
97
|
+
|
|
98
|
+
/* Slot explorer shares the stage row; bottom Inspector remains in the main column. */
|
|
99
|
+
.layout{grid-template-columns:150px minmax(0,1fr) 235px!important}.parts-explorer{display:block;grid-template-columns:none;gap:normal;min-width:0;min-height:0;overflow:auto;border-left:1px solid #49616d;background:#182d38;padding:12px;overscroll-behavior:contain}.parts-explorer h2{font-size:12px;margin:0 0 10px}.parts-explorer>label{display:block;font-size:11px;margin-top:12px}.parts-explorer input[type=search]{width:100%;min-width:0}.parts-explorer .part-solo{display:flex;gap:6px;align-items:center}.parts-explorer ul{list-style:none;margin:8px 0;padding:0;display:grid;gap:4px}.parts-explorer li{min-width:0}.parts-explorer li button{display:block;width:100%;text-align:left;overflow-wrap:anywhere;font-size:11px}.parts-explorer button[aria-pressed=true]{background:#355445;border-color:#9cd3b4}.parts-explorer h2,#selected-part h3{font-weight:600}#selected-part{margin-bottom:12px}#selected-part h3{margin:0 0 8px;overflow-wrap:anywhere}#selected-part p{overflow-wrap:anywhere}[data-theme=light] .parts-explorer{background:#edf3f5;border-color:#a6bec8;color:#294552}[data-theme=light] .parts-explorer button[aria-pressed=true]{background:#c5ddcd;color:#183c2c}
|
|
100
|
+
@media(max-width:1050px){.layout{grid-template-columns:110px minmax(0,1fr) 185px!important}.parts-explorer{padding:8px}}
|
|
101
|
+
@media(max-width:700px){.layout{grid-template-columns:minmax(0,1fr) 165px!important}.left-panel{display:none!important}}
|
|
102
|
+
@media(max-width:460px){.layout{grid-template-columns:minmax(0,1fr) 132px!important}.parts-explorer{padding:6px}.parts-explorer li button{padding:6px}.parts-explorer>.hint{font-size:10px}}
|
|
103
|
+
|
|
104
|
+
.layout{grid-template-columns:minmax(0,1fr) 235px!important}@media(max-width:1050px){.layout{grid-template-columns:minmax(0,1fr) 185px!important}}@media(max-width:700px){.layout{grid-template-columns:minmax(0,1fr) 165px!important}}@media(max-width:460px){.layout{grid-template-columns:minmax(0,1fr) 132px!important}}
|
|
105
|
+
|
|
106
|
+
/* Resizable workspace: one measured shell; workspace-ui clamps the desired browser sizes. */
|
|
107
|
+
.layout.workspace-layout{display:grid;grid-template-columns:var(--workspace-columns)!important;min-height:0!important;overflow:hidden}
|
|
108
|
+
.workspace-layout>main{display:grid;min-width:0;min-height:0;overflow:hidden;grid-column:3;grid-row:1}
|
|
109
|
+
.workspace-stage-stack{grid-row:1;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
|
|
110
|
+
.workspace-stage-stack>#stage-layout{flex:1;min-height:0;height:auto;overflow:hidden}
|
|
111
|
+
.workspace-layout>.left-panel{grid-column:1;grid-row:1;display:flex!important;flex-direction:column;overflow:hidden;min-width:0;padding:0;border-right:1px solid #49616d}
|
|
112
|
+
.workspace-sources{flex:1;min-height:0;overflow:auto;padding:10px;overscroll-behavior:contain}
|
|
113
|
+
.workspace-settings{flex:none;max-height:65%;overflow:auto;padding:10px;border-top:1px solid #49616d}
|
|
114
|
+
.workspace-settings h2{font-size:12px;margin:0 0 8px}
|
|
115
|
+
.workspace-settings label{display:flex;align-items:center;gap:6px;font-size:11px;margin:7px 0}
|
|
116
|
+
.workspace-settings select{width:auto;min-width:0;flex:1}
|
|
117
|
+
.workspace-settings .hint{margin:6px 0}
|
|
118
|
+
.workspace-settings input[type=checkbox]{width:auto;flex:none}
|
|
119
|
+
.workspace-layout>.parts-explorer{display:block;grid-template-columns:none;grid-column:5;grid-row:1;width:auto;min-width:0;min-height:0;height:auto;overflow:auto}
|
|
120
|
+
.workspace-layout .inspector{grid-row:3;height:auto!important;min-height:0!important;max-height:none;overflow:hidden}
|
|
121
|
+
.workspace-separator{position:relative;z-index:3;background:#34515e;touch-action:none;cursor:col-resize;min-width:0;min-height:0}
|
|
122
|
+
.workspace-separator::after{content:'';position:absolute;inset:0 -4px}
|
|
123
|
+
.workspace-separator:focus-visible{outline:2px solid #c8e8d6;outline-offset:-2px;background:#719581}
|
|
124
|
+
.workspace-separator[data-resizing=true]{background:#8ac9a3}
|
|
125
|
+
.workspace-leftWidth{grid-column:2;grid-row:1}.workspace-rightWidth{grid-column:4;grid-row:1}
|
|
126
|
+
.workspace-inspectorHeight{grid-row:2;cursor:row-resize}.workspace-inspectorHeight::after{inset:-4px 0}
|
|
127
|
+
.workspace-drawer-triggers{display:flex;gap:6px;flex-wrap:wrap;flex:none;padding:4px 8px;min-height:0}
|
|
128
|
+
.workspace-drawer-triggers:not(:has(button:not([hidden]))){display:none}
|
|
129
|
+
.workspace-drawer-triggers button{padding:4px 7px;font-size:11px;min-height:28px}
|
|
130
|
+
.workspace-drawer{position:fixed;inset:0;width:min(94vw,950px);height:min(92dvh,850px);max-width:94vw;max-height:92dvh;margin:auto;padding:8px;border:1px solid #739383;border-radius:8px;background:#182d38;color:inherit;overflow:hidden;z-index:20}
|
|
131
|
+
.workspace-drawer:not([open]){display:none}.workspace-drawer[open]{display:flex;flex-direction:column;gap:8px}
|
|
132
|
+
.workspace-drawer::backdrop{background:#07141ed0}
|
|
133
|
+
.workspace-drawer-close{align-self:flex-end;flex:none}
|
|
134
|
+
.workspace-drawer>.left-panel{display:flex!important;flex-direction:column;flex:1;overflow:hidden;min-height:0;padding:0}
|
|
135
|
+
.workspace-drawer>.parts-explorer{display:block;grid-template-columns:none;flex:1;width:auto;min-width:0;min-height:0;height:auto;overflow:auto}
|
|
136
|
+
.workspace-drawer>.inspector{flex:1;height:auto!important;min-height:0!important;max-height:none;overflow:hidden}
|
|
137
|
+
[data-theme=light] .workspace-settings,[data-theme=light] .workspace-drawer{background:#edf3f5;color:#294552;border-color:#a6bec8}
|
|
138
|
+
[data-theme=light] .workspace-separator{background:#b2c7cf}[data-theme=light] .workspace-separator:focus-visible{outline-color:#24694d;background:#70a58c}
|
|
139
|
+
@media(pointer:coarse){.workspace-separator::after{inset:0 -7px}.workspace-inspectorHeight::after{inset:-7px 0}.workspace-drawer-triggers button{min-height:38px}}
|
|
140
|
+
#stage-layout[data-panes="1"]{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}
|