WEB Session Lifecycle Observability + Bridge Recovery Drafts

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-09-01 19:19:52 +03:00
parent aec7d1619a
commit 01ffca5d34
47 changed files with 1678 additions and 441 deletions
+183 -205
View File
@@ -1,20 +1,25 @@
(()=>{'use strict';
const bootstrap="__BOOTSTRAP__";
let bootstrap="__BOOTSTRAP__";
const relayOrigin='https://__HOST__',carrierCapabilities='https,https-lanes,websocket,websocket-lanes';
const responseBody=globalThis.TelemtBridgeResponse;if(!responseBody)throw new Error('missing response runtime');
const negotiationEnabled=__NEGOTIATION_ENABLED__,candidateCount=__CANDIDATE_COUNT__,candidateDeadlines=[__CARRIER_DEADLINES__];
const longPollMs=__LONG_POLL_SECS__*1000,bridgeRequestMs=__BRIDGE_REQUEST_SECS__*1000,bridgeRetryMs=__BRIDGE_RETRY_SECS__*1000;
const probeCoalesceMs=__CARRIER_PROBE_COALESCE_MS__;
const requestSupport=globalThis.TelemtBridgeRequest;if(!requestSupport)throw new Error('missing request runtime');
const bufferSupport=globalThis.TelemtBridgeBuffers;if(!bufferSupport)throw new Error('missing buffer runtime');
const recoverySupport=globalThis.TelemtBridgeRecovery;if(!recoverySupport)throw new Error('missing recovery runtime');
let negotiationEnabled=__NEGOTIATION_ENABLED__,candidateCount=__CANDIDATE_COUNT__,candidateDeadlines=[__CARRIER_DEADLINES__];
let longPollMs=__LONG_POLL_SECS__*1000,bridgeRequestMs=__BRIDGE_REQUEST_SECS__*1000,bridgeRetryMs=__BRIDGE_RETRY_SECS__*1000;
let bridgeRecoveryMs=__BRIDGE_RECOVERY_SECS__*1000,websocketOpenMs=__WEBSOCKET_OPEN_SECS__*1000,reconnectGraceMs=__RECONNECT_GRACE_SECS__*1000;
let probeCoalesceMs=__CARRIER_PROBE_COALESCE_MS__;
let negotiatedCandidateCount=candidateCount,negotiatedFinalDeadline=candidateDeadlines[3],negotiatedFrozen=false;
const batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__;
const laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItemLimit,1024),closedLaneLimit=4096;
const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||'';
let batchLimit=__BATCH_LIMIT__,queueLimit=__QUEUE_LIMIT__,queueItemLimit=__QUEUE_ITEMS__,maxStreams=__MAX_STREAMS__;
let laneQueueLimit=Math.min(queueLimit,8388608),laneItemLimit=Math.min(queueItemLimit,1024);const closedLaneLimit=4096;
const fragment=location.hash,androidNonce=/^#android=([A-Za-z0-9_-]{43})$/.exec(fragment)?.[1]||'',recoveryPath=location.pathname+location.search;
history.replaceState(null,'',location.pathname);
let initialized=false,closed=false,port=null,sessionToken='',cleanupToken='',createStarted=false,socket=null,socketReady=false,carrier='';
let queuedBytes=0,queuedItems=0,upSequence=1,downCursor='0',upRunning=false,upLease=null,pollController=null;
let upSequence=1,downCursor='0',upRunning=false,upLease=null,pollController=null;
let helloFrame=null,helloTimer=null,welcomeSent=false,carrierAttempt=1,carrierFailure='',carrierCommitted=false,terminalFailure='';
let negotiationStartedAt=0,carrierTimer=null,probeTimer=null,attemptController=null,attemptEpoch=1,candidateRunning=false,switching=false,currentAttempt=null;
const pending=[],upPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
let recoveryController=null,recoveryCommit=null,recoveryReplaced=false,lastSchedulerWall=Date.now(),lastSchedulerMonotonic=performance.now();
const pending=[],upPending=[],recoveryPending=[],lanes=new Map(),closedLanes=new Set(),closedLaneOrder=[];
const canonicalFailures=['timeout','network','upgrade','http','protocol'];
const failure=(reason,message)=>Object.assign(new Error(message||reason),{telemtReason:reason});
const failureReason=(error,fallback)=>error&&canonicalFailures.includes(error.telemtReason)?error.telemtReason:fallback;
@@ -25,182 +30,84 @@ const status=(state,phase,reason,deadlineMs)=>{
if(currentDeadline===undefined)currentDeadline=negotiationStartedAt?Math.max(0,negotiationStartedAt+negotiatedFinalDeadline*1000-Date.now()):0;
port.postMessage({t:'status',state,phase:currentPhase,reason:reason||'',deadline_ms:Math.max(0,Math.ceil(currentDeadline))});
};
const pause=(milliseconds,signal)=>new Promise((resolve,reject)=>{
if(signal&&signal.aborted){reject(new Error('request aborted'));return}
const timer=setTimeout(done,milliseconds);function done(){if(signal)signal.removeEventListener('abort',abort);resolve()}
function abort(){clearTimeout(timer);signal.removeEventListener('abort',abort);reject(new Error('request aborted'))}
if(signal)signal.addEventListener('abort',abort,{once:true});
});
const socketURL=()=>relayOrigin.replace(/^https:/,'wss:')+'/api/v1/ws';
const options=(method,token,body,headers,signal,keepalive)=>({
method,body,signal,keepalive:!!keepalive,mode:'same-origin',credentials:'omit',cache:'no-store',redirect:'error',referrerPolicy:'no-referrer',
headers:Object.assign(token?{Authorization:'Bearer '+token}:{},body?{'Content-Type':'application/octet-stream'}:{},headers||{})
const requestClient=requestSupport.create({
origin:()=>relayOrigin,closed:()=>closed,retryMs:()=>bridgeRetryMs,longPollMs:()=>longPollMs,requestMs:()=>bridgeRequestMs,
batchLimit:()=>batchLimit,read:(response,limit,exact,signal)=>responseBody.read(response,limit,exact,signal),cancel:responseBody.cancel,
failure,reason:failureReason,retrying:()=>status('reconnecting')
});
const options=requestClient.options,pause=requestClient.pause,request=requestClient.send;
const buffers=bufferSupport.create({
limits:()=>({batchBytes:batchLimit,queueBytes:queueLimit,queueItems:queueItemLimit,laneBytes:laneQueueLimit,laneItems:laneItemLimit}),
buffered:()=>{let total=socket?socket.bufferedAmount:0;for(const value of lanes.values())if(value.socket)total+=value.socket.bufferedAmount;return total},
pending:()=>pending,laneMode:()=>carrier==='https-lanes'||carrier==='websocket-lanes',maxStreams:()=>maxStreams,failure
});
const {reserve,release,releasePending,frameBound,splitFrames,acceptNativeFrames,observeServerFrames,findProbe,consumeProbe,takeBatch,closeFrame,retireStream,retireAllStreams,clearStreams}=buffers;
function detachLease(lease){if(lease.lane){if(lease.lane.upLease===lease)lease.lane.upLease=null}else if(upLease===lease)upLease=null}
function settleBatch(lease){if(!buffers.settleBatch(lease))return false;detachLease(lease);return true}
function cancelBatch(lease){if(!lease||lease.settled)return;buffers.cancelBatch(lease);detachLease(lease)}
const attemptHeaders=(attempt,failure)=>negotiationEnabled?Object.assign({'X-Carrier-Capabilities':carrierCapabilities,'X-Carrier-Attempt':String(attempt)},failure?{'X-Carrier-Failure':failure}:{}):{};
function reserve(data,lane){
let buffered=socket?socket.bufferedAmount:0;for(const value of lanes.values())if(value.socket)buffered+=value.socket.bufferedAmount;
if(!data.byteLength||data.byteLength>queueLimit-queuedBytes-buffered||queuedItems>=queueItemLimit)return false;
if(lane&&(data.byteLength>laneQueueLimit-lane.bytes-(lane.socket?lane.socket.bufferedAmount:0)||lane.items>=laneItemLimit))return false;
queuedBytes+=data.byteLength;queuedItems++;if(lane){lane.bytes+=data.byteLength;lane.items++}return true;
function finishOldRecovery(){
recoveryReplaced=false;status('connected','committed','',0);
for(const data of recoveryPending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
}
function release(bytes,items,lane){
if(bytes>queuedBytes||items>queuedItems||(lane&&(bytes>lane.bytes||items>lane.items)))throw new Error('queue accounting invariant');
queuedBytes-=bytes;queuedItems-=items;if(lane){lane.bytes-=bytes;lane.items-=items}
function rejectRecoveryCommit(error){
const commit=recoveryCommit;if(!commit)return;recoveryCommit=null;
commit.signal.removeEventListener('abort',commit.abort);commit.reject(error);
}
function releasePending(values,lane){
if(!values.length)return;let bytes=0;for(const value of values)bytes+=value.byteLength;
const items=values.length;values.length=0;release(bytes,items,lane);
function resolveRecoveryCommit(){
const commit=recoveryCommit;if(!commit)return;recoveryCommit=null;recoveryReplaced=false;
commit.signal.removeEventListener('abort',commit.abort);commit.resolve();
}
function frameBound(value,maxFrames,maxBytes){
const view=new DataView(value);let offset=0,frames=0;
while(offset<value.byteLength){
if(value.byteLength-offset<8)throw new Error('invalid frame batch');
const size=view.getUint32(offset+4),end=offset+8+size;
if(size>1048576||end>value.byteLength)throw new Error('invalid frame');
if(frames>0&&(frames>=maxFrames||end>maxBytes))break;
frames++;offset=end;
function retireCarrier(policy){
recoveryReplaced=true;attemptEpoch++;if(carrierTimer)clearTimeout(carrierTimer);carrierTimer=null;clearProbeTimer();
if(attemptController)attemptController.abort();attemptController=null;if(pollController)pollController.abort();pollController=null;
if(socket){const previous=socket;socket=null;previous.close()}socketReady=false;cancelBatch(upLease);releasePending(upPending,null);
for(const lane of lanes.values()){
if(lane.controller)lane.controller.abort();cancelBatch(lane.upLease);releasePending(lane.pending,lane);if(lane.socket)lane.socket.close();
}
if(!frames)throw new Error('empty frame batch');
return {frames,bytes:offset};
lanes.clear();closedLanes.clear();closedLaneOrder.length=0;releasePending(pending,null);releasePending(recoveryPending,null);
for(const id of retireAllStreams())if(port){const frame=closeFrame(id);port.postMessage(frame,[frame])}
bootstrap=policy.bootstrap;batchLimit=policy.limits.carrier_batch_bytes;queueLimit=policy.limits.pending_bytes_per_session;
queueItemLimit=policy.limits.pending_items_per_session;maxStreams=policy.limits.max_streams_per_session;
laneQueueLimit=Math.min(queueLimit,8388608);laneItemLimit=Math.min(queueItemLimit,1024);
longPollMs=policy.timeouts.long_poll_secs*1000;bridgeRequestMs=policy.timeouts.bridge_request_secs*1000;
bridgeRetryMs=policy.timeouts.bridge_retry_secs*1000;bridgeRecoveryMs=policy.timeouts.bridge_recovery_secs*1000;
websocketOpenMs=policy.timeouts.websocket_open_secs*1000;reconnectGraceMs=policy.timeouts.reconnect_grace_secs*1000;
negotiationEnabled=policy.negotiation.enabled;candidateCount=policy.negotiation.candidate_count;
candidateDeadlines=policy.negotiation.deadlines_secs;probeCoalesceMs=policy.negotiation.carrier_probe_coalesce_ms;
negotiatedCandidateCount=candidateCount;negotiatedFinalDeadline=candidateDeadlines[3];negotiatedFrozen=false;
sessionToken='';cleanupToken='';carrier='';carrierAttempt=1;carrierFailure='';carrierCommitted=false;
candidateRunning=false;switching=false;currentAttempt=null;upSequence=1;downCursor='0';upRunning=false;
}
function splitFrames(value){
const view=new DataView(value),result=[];let offset=0;
while(offset<value.byteLength){
if(value.byteLength-offset<8||result.length>=4096)throw new Error('invalid frame batch');
const type=view.getUint8(offset),id=(view.getUint8(offset+1)<<16)|(view.getUint8(offset+2)<<8)|view.getUint8(offset+3);
const size=view.getUint32(offset+4),end=offset+8+size;
if((type===2&&!size)||size>1048576||end>value.byteLength)throw new Error('invalid frame');
result.push({type,id,data:offset===0&&end===value.byteLength?value:value.slice(offset,end)});offset=end;
}
if(!result.length)throw new Error('empty frame batch');return result;
function replaceCarrier(policy,signal,remaining){
if(closed||!helloFrame||!port)throw failure('protocol','missing recovery owner');
retireCarrier(policy);negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch);
return new Promise((resolve,reject)=>{
const abort=()=>rejectRecoveryCommit(failure('timeout','recovery deadline'));
recoveryCommit={resolve,reject,signal,abort};signal.addEventListener('abort',abort,{once:true});
if(remaining()<=0){abort();return}createSession(attemptEpoch);
});
}
function probeFrames(){
const result=[];let scanned=0;
for(let index=0;index<pending.length;index++){
const source=pending[index],view=new DataView(source);let start=0;
while(start<source.byteLength){
if(source.byteLength-start<8||result.length>=4096)throw new Error('invalid frame batch');
const type=view.getUint8(start),id=(view.getUint8(start+1)<<16)|(view.getUint8(start+2)<<8)|view.getUint8(start+3);
const size=view.getUint32(start+4),end=start+8+size,bytes=end-start;
if((type===2&&!size)||size>1048576||end>source.byteLength)throw new Error('invalid frame');
if(scanned+bytes>batchLimit)return result;
result.push({source,index,start,end,type,id});scanned+=bytes;start=end;
}
}
return result;
function recoverTransport(error,replay){
if(closed)return Promise.resolve(false);
const reason=failureReason(error,'network');
if(reason==='protocol'){fail(reason);return Promise.resolve(false)}
return recoveryController.recover(reason,replay);
}
function findProbe(includeData){
const frames=probeFrames(),first=frames.findIndex(frame=>frame.type===1||frame.type===2);if(first<0)return null;
const laneMode=carrier==='https-lanes'||carrier==='websocket-lanes',selected=[];let hasData=frames[first].type===2;
if(laneMode){
selected.push(frames[first]);
if(includeData&&!hasData)for(let index=first+1;index<frames.length;index++)if(frames[index].id===frames[first].id){selected.push(frames[index]);if(frames[index].type===2){hasData=true;break}}
}else{
let last=first;
if(includeData&&!hasData)for(let index=first+1;index<frames.length;index++){if(frames[index].id===frames[first].id&&frames[index].type===2){last=index;hasData=true;break}}
for(let index=0;index<=(hasData?last:first);index++)selected.push(frames[index]);
}
const spans=[];let total=0;
for(const frame of selected){
const previous=spans[spans.length-1];total+=frame.end-frame.start;
if(previous&&previous.source===frame.source&&previous.end===frame.start)previous.end=frame.end;
else spans.push({source:frame.source,index:frame.index,start:frame.start,end:frame.end});
}
const joined=new Uint8Array(total);let offset=0;
for(const span of spans){const part=new Uint8Array(span.source,span.start,span.end-span.start);joined.set(part,offset);offset+=part.byteLength}
return {spans,id:frames[first].id,data:joined.buffer,hasData};
function schedulerGap(){
const wall=Date.now(),monotonic=performance.now();
const gap=Math.max(0,wall-lastSchedulerWall,monotonic-lastSchedulerMonotonic);
lastSchedulerWall=wall;lastSchedulerMonotonic=monotonic;return gap;
}
function consumeProbe(probe){
const groups=new Map();
for(const span of probe.spans){if(pending[span.index]!==span.source)throw new Error('stale carrier probe');const values=groups.get(span.index)||[];values.push(span);groups.set(span.index,values)}
const indexes=Array.from(groups.keys()).sort((left,right)=>right-left);
for(const index of indexes){
const source=pending[index],spans=groups.get(index).sort((left,right)=>left.start-right.start);let removed=0,offset=0;
for(const span of spans){if(span.start<offset)throw new Error('overlapping carrier probe');removed+=span.end-span.start;offset=span.end}
if(removed===source.byteLength){pending.splice(index,1);release(removed,1,null);continue}
const merged=new Uint8Array(source.byteLength-removed);let write=0;offset=0;
for(const span of spans){merged.set(new Uint8Array(source,offset,span.start-offset),write);write+=span.start-offset;offset=span.end}
merged.set(new Uint8Array(source,offset),write);pending[index]=merged.buffer;release(removed,0,null);
}
}
function joinPending(values,lane){
let total=0,count=0,frames=0;
while(count<values.length){
const bound=frameBound(values[count],4096,batchLimit),whole=bound.bytes===values[count].byteLength;
if(count===0&&!whole){
const head=new Uint8Array(values[0],0,bound.bytes).slice();
values[0]=values[0].slice(bound.bytes);queuedItems++;if(lane)lane.items++;
return {body:head.buffer,total:bound.bytes,count:1};
}
if(count&&(total+values[count].byteLength>batchLimit||frames+bound.frames>4096))break;
total+=values[count].byteLength;frames+=bound.frames;count++;
}
const joined=new Uint8Array(total);let offset=0;
for(const data of values.splice(0,count)){joined.set(new Uint8Array(data),offset);offset+=data.byteLength}
return {body:joined.buffer,total,count};
}
function takeBatch(values,lane){
const batch=joinPending(values,lane);
return Object.assign(batch,{lane,controller:null,cancelled:false,settled:false});
}
function settleBatch(lease){
if(!lease||lease.settled)return false;lease.settled=true;
if(lease.lane){if(lease.lane.upLease===lease)lease.lane.upLease=null}else if(upLease===lease)upLease=null;
release(lease.total,lease.count,lease.lane);return true;
}
function cancelBatch(lease){
if(!lease||lease.settled)return;lease.cancelled=true;if(lease.controller)lease.controller.abort();settleBatch(lease);
}
function retryAfterMs(response){
const header=response.headers.get('Retry-After');
if(!header)return 0;
const seconds=Number(header);
if(Number.isFinite(seconds)&&seconds>=0)return Math.min(seconds*1000,30000);
const when=Date.parse(header);
if(Number.isFinite(when)){const delta=when-Date.now();return delta>0?Math.min(delta,30000):0}
return 0;
}
function retryableStatus(status){return status===408||status===429||status===502||status===503||status===504}
function responsePolicy(path,status){
if(path==='/api/v1/session'&&status===200)return {limit:8,exact:true};
if(path==='/api/v1/down'&&status===200)return {limit:batchLimit,exact:false};
return {limit:0,exact:true};
}
async function request(path,frozenOptions){
let delay=250,attempt=0,lastReason='network';const deadline=Date.now()+bridgeRetryMs,external=frozenOptions.signal;
const attemptLimit=path==='/api/v1/down'?longPollMs+bridgeRequestMs:bridgeRequestMs;
while(attempt<9){
if(closed||(external&&external.aborted))throw new Error('request aborted');
const remaining=deadline-Date.now();if(remaining<=0)break;attempt++;
const controller=new AbortController(),abort=()=>controller.abort();
if(external)external.addEventListener('abort',abort,{once:true});
const requestOptions=Object.assign({},frozenOptions,{signal:controller.signal});
const timer=setTimeout(abort,Math.max(1,Math.min(attemptLimit,remaining)));
let response=null,wait=0;
try{
const fetched=await fetch(relayOrigin+path,requestOptions);
if(retryableStatus(fetched.status)){
lastReason='http';wait=retryAfterMs(fetched);responseBody.cancel(fetched);
}else{
const policy=responsePolicy(path,fetched.status);let body;
try{body=await responseBody.read(fetched,policy.limit,policy.exact,controller.signal)}
catch(error){controller.abort();throw failure('protocol',error&&error.message)}
response={status:fetched.status,headers:fetched.headers,body};return response;
}
}catch(error){
controller.abort();
if(closed||(external&&external.aborted))throw error;
if(failureReason(error,'')==='protocol')throw error;
}finally{clearTimeout(timer);if(external)external.removeEventListener('abort',abort)}
const after=deadline-Date.now();if(attempt>=9||after<=0)break;
status('reconnecting');
const backoff=wait||delay+Math.floor(Math.random()*Math.max(1,delay/4));
await pause(Math.min(backoff,after),external);delay=Math.min(delay*2,5000);
}
throw failure(lastReason,'carrier retry limit reached');
function observeResumeTrigger(){
const gap=schedulerGap();if(!carrierCommitted||closed)return;
if(gap>=2*longPollMs)status('reconnecting','retrying','',bridgeRecoveryMs);
if(gap>=reconnectGraceMs&&!recoveryController.active())recoveryController.recover('timeout',null);
}
function fail(reason){
if(closed)return;reason=reason||'protocol';if(canonicalFailures.includes(reason))terminalFailure=reason;
rejectRecoveryCommit(failure(reason));
status('failed','terminal',reason,0);if(port)port.postMessage({t:'close'});close(true);
}
function knownCarrier(value){return value==='https'||value==='https-lanes'||value==='websocket'||value==='websocket-lanes'}
@@ -333,6 +240,7 @@ function commitCarrier(probe,epoch){
if(carrier==='https')poll();
else if(carrier==='https-lanes'){const lane=lanes.get(probe.id);if(lane&&!lane.polling)pollLane(lane)}
for(const data of pending.splice(0)){release(data.byteLength,1,null);queueCarrier(data)}
resolveRecoveryCommit();
}
function queueCarrier(data){
try{
@@ -346,10 +254,22 @@ async function runUp(){
if(upRunning)return;upRunning=true;let lease=null;
try{
while(!closed&&sessionToken&&upPending.length){
lease=takeBatch(upPending,null);upLease=lease;lease.controller=new AbortController();const sequence=String(upSequence);
const response=await request('/api/v1/up',options('POST',sessionToken,lease.body,{'X-Up-Seq':sequence},lease.controller.signal));
if(response.status!==204)throw failure('http','uplink rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','uplink acknowledgement rejected');
lease=takeBatch(upPending,null);upLease=lease;lease.controller=new AbortController();const sequence=String(upSequence),token=sessionToken;
for(;;){
try{
const response=await request('/api/v1/up',options('POST',token,lease.body,{'X-Up-Seq':sequence},lease.controller.signal),null,1);
if(response.status!==204)throw failure('http','uplink rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','uplink acknowledgement rejected');
break;
}catch(error){
const recovered=await recoverTransport(error,async(signal,remaining)=>{
const response=await request('/api/v1/up',options('POST',token,lease.body,{'X-Up-Seq':sequence},signal),remaining,2);
if(response.status!==204)throw failure('http','uplink replay rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','uplink replay acknowledgement rejected');
});
if(!recovered||closed||lease.cancelled||sessionToken!==token)return;
}
}
if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});upSequence++;lease=null;
}
}catch(error){if(!closed&&!(lease&&lease.cancelled))fail(failureReason(error,'network'))}
@@ -359,13 +279,17 @@ function sendCandidateSocket(next){
const state=next.telemt;if(!state||state.sent||next.readyState!==WebSocket.OPEN||!state.probe)return;
let probe=state.probe;
try{const fresh=findProbe(true);if(fresh&&fresh.id===probe.id)probe=fresh;next.send(probe.data)}catch(error){advanceCarrier('upgrade',state.epoch);return}
state.probe=probe;state.sent=true;if(!negotiationEnabled)commitCarrier(probe,state.epoch);
state.probe=probe;state.sent=true;if(!negotiationEnabled){if(state.openTimer)clearTimeout(state.openTimer);state.openTimer=null;commitCarrier(probe,state.epoch)}
}
function openCandidateSocket(probe,laneID,epoch){
let lane=laneID===null?null:ensureLane(laneID),next=lane?lane.socket:socket;
if(next){if(!next.telemt||next.telemt.epoch!==epoch){advanceCarrier('protocol',epoch);return}if(probe)next.telemt.probe=probe;sendCandidateSocket(next);return}
const token=sessionToken,protocol=laneID===null?(negotiationEnabled?'tproxy-auto-v1.':'tproxy-v1.')+token:(negotiationEnabled?'tproxy-auto-lane-v1.':'tproxy-lane-v1.')+token+'.'+String(laneID);
next=new WebSocket(socketURL(),protocol);next.binaryType='arraybuffer';next.telemt={epoch,lane,probe,opened:false,sent:false};
next=new WebSocket(socketURL(),protocol);next.binaryType='arraybuffer';next.telemt={epoch,lane,probe,opened:false,sent:false,openTimer:null};
next.telemt.openTimer=setTimeout(()=>{
const state=next.telemt;if(closed||state.epoch!==attemptEpoch)return;
next.close();advanceCarrier('timeout',state.epoch);
},websocketOpenMs);
if(lane)lane.socket=next;else socket=next;
next.onopen=()=>{
const state=next.telemt;if(closed||state.epoch!==attemptEpoch){next.close();return}state.opened=true;
@@ -373,18 +297,19 @@ function openCandidateSocket(probe,laneID,epoch){
};
next.onmessage=event=>{
const state=next.telemt;if(closed||state.epoch!==attemptEpoch||!(event.data instanceof ArrayBuffer))return;
if(state.openTimer)clearTimeout(state.openTimer);state.openTimer=null;
if(!carrierCommitted){if(!state.sent||event.data.byteLength!==0){advanceCarrier('protocol',state.epoch);return}commitCarrier(state.probe,state.epoch);return}
try{
if(state.lane){const values=splitFrames(event.data);for(const value of values)if(value.id!==state.lane.id)throw new Error('cross-lane frame');if(values.some(value=>value.type===3))state.lane.remoteClosed=true}
else{const bound=frameBound(event.data,4096,batchLimit);if(bound.bytes!==event.data.byteLength)throw new Error('invalid frame batch')}
}catch(error){if(state.lane)finishLane(state.lane,true);else fail('protocol');return}
port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
observeServerFrames(event.data);port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
};
next.onerror=()=>{};
next.onclose=()=>{
const state=next.telemt;if(state.epoch!==attemptEpoch||closed)return;
const state=next.telemt;if(state.openTimer)clearTimeout(state.openTimer);state.openTimer=null;if(state.epoch!==attemptEpoch||closed)return;
if(!carrierCommitted){advanceCarrier(state.opened?'network':'upgrade',state.epoch);return}
if(state.lane){state.lane.ready=false;state.lane.socket=null;finishLane(state.lane,true)}else{socketReady=false;fail('network')}
if(state.lane){state.lane.ready=false;state.lane.socket=null;finishLane(state.lane,true)}else{socketReady=false;recoveryController.recover('network',null)}
};
}
function queueSocket(data){if(!reserve(data,null)){fail('capacity');return}upPending.push(data);runSocketUp()}
@@ -400,21 +325,30 @@ async function runSocketUp(){
await waitSocket(socket,lease.total,queueLimit,lease.controller.signal);socket.send(lease.body);
if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});lease=null;
}
}catch(error){if(!closed&&!(lease&&lease.cancelled))fail(failureReason(error,'network'))}
}catch(error){if(!closed&&!(lease&&lease.cancelled))recoverTransport(error,null)}
finally{upRunning=false;if(!closed&&socketReady&&upPending.length)runSocketUp()}
}
async function poll(){
while(!closed&&sessionToken){
const token=sessionToken,cursor=downCursor;
try{
pollController=new AbortController();
const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':downCursor},pollController.signal));
const response=await request('/api/v1/down',options('POST',token,null,{'X-Down-Cursor':cursor},pollController.signal),null,1);
if(response.status===204){status('connected');continue}
if(response.status!==200)throw failure('http','downlink rejected');
const next=response.headers.get('X-Down-Cursor')||'',data=response.body;
if(!next||!data.byteLength)throw failure('protocol','invalid downlink response');
if(closed)return;
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);downCursor=next;status('connected');
}catch(error){if(!closed)fail(failureReason(error,'network'));return}
observeServerFrames(data);port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);downCursor=next;status('connected');
}catch(error){
if(closed)return;
const recovered=await recoverTransport(error,async(signal,remaining)=>{
const response=await request('/api/v1/down',options('POST',token,null,{'X-Down-Cursor':cursor},signal),remaining,2);
if(response.status===204)return;
if(response.status!==200||!response.body.byteLength||!response.headers.get('X-Down-Cursor'))throw failure('http','downlink replay rejected');
});
if(!recovered||closed||sessionToken!==token)return;
}
}
}
function ensureLane(id){
@@ -427,13 +361,12 @@ function rememberLaneClosed(id){
if(closedLaneOrder.length===closedLaneLimit)closedLanes.delete(closedLaneOrder.shift());
closedLanes.add(id);closedLaneOrder.push(id);
}
function closeFrame(id){const value=new Uint8Array(8);value[0]=3;value[1]=(id>>>16)&255;value[2]=(id>>>8)&255;value[3]=id&255;return value.buffer}
function finishLane(lane,notifyClient){
if(lanes.get(lane.id)!==lane)return;
if(lane.controller)lane.controller.abort();lane.controller=null;cancelBatch(lane.upLease);
if(lane.socket&&lane.socket.readyState<WebSocket.CLOSING)lane.socket.close();
releasePending(lane.pending,lane);lanes.delete(lane.id);rememberLaneClosed(lane.id);
if(notifyClient&&!lane.remoteClosed&&port){const frame=closeFrame(lane.id);port.postMessage(frame,[frame])}
if(notifyClient&&!lane.remoteClosed&&port){retireStream(lane.id);const frame=closeFrame(lane.id);port.postMessage(frame,[frame])}
}
function queueLane(value){
let lane=lanes.get(value.id);
@@ -447,14 +380,16 @@ function queueLane(value){
}
function openLaneSocket(lane){
if(lane.socket||closed)return;lane.socket=new WebSocket(socketURL(),'tproxy-lane-v1.'+sessionToken+'.'+String(lane.id));lane.socket.binaryType='arraybuffer';
const opened=lane.socket,openTimer=setTimeout(()=>{if(!closed&&lanes.get(lane.id)===lane&&lane.socket===opened)finishLane(lane,true)},websocketOpenMs);
lane.socket.onopen=()=>{if(closed||lanes.get(lane.id)!==lane)return;lane.ready=true;status('connected');runLaneSocketUp(lane)};
lane.socket.onmessage=event=>{
clearTimeout(openTimer);
if(closed||lanes.get(lane.id)!==lane||!(event.data instanceof ArrayBuffer)){finishLane(lane,true);return}
let values;try{values=splitFrames(event.data);for(const value of values)if(value.id!==lane.id)throw new Error('cross-lane frame')}catch(error){finishLane(lane,true);return}
if(values.some(value=>value.type===3))lane.remoteClosed=true;
port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
observeServerFrames(event.data);port.postMessage({t:'traffic',up:0,down:event.data.byteLength});port.postMessage(event.data,[event.data]);status('connected');
};
lane.socket.onerror=()=>{};lane.socket.onclose=()=>{lane.ready=false;lane.socket=null;if(!closed)finishLane(lane,true)};
lane.socket.onerror=()=>{};lane.socket.onclose=()=>{clearTimeout(openTimer);lane.ready=false;lane.socket=null;if(!closed)finishLane(lane,true)};
}
async function runLaneSocketUp(lane){
if(lane.running||!lane.ready)return;lane.running=true;let lease=null;
@@ -472,10 +407,22 @@ async function runLaneUp(lane){
try{
while(!closed&&sessionToken&&lane.pending.length){
lease=takeBatch(lane.pending,lane);lane.upLease=lease;lease.controller=new AbortController();
const sequence=String(lane.sequence),laneID=String(lane.id);
const response=await request('/api/v1/up',options('POST',sessionToken,lease.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID},lease.controller.signal));
if(response.status!==204)throw failure('http','lane uplink rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','lane uplink acknowledgement rejected');
const sequence=String(lane.sequence),laneID=String(lane.id),token=sessionToken;
for(;;){
try{
const response=await request('/api/v1/up',options('POST',token,lease.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID},lease.controller.signal),null,1);
if(response.status!==204)throw failure('http','lane uplink rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','lane uplink acknowledgement rejected');
break;
}catch(error){
const recovered=await recoverTransport(error,async(signal,remaining)=>{
const response=await request('/api/v1/up',options('POST',token,lease.body,{'X-Up-Seq':sequence,'X-Lane-ID':laneID},signal),remaining,2);
if(response.status!==204)throw failure('http','lane uplink replay rejected');
if(response.headers.get('X-Up-Ack')!==sequence)throw failure('protocol','lane uplink replay acknowledgement rejected');
});
if(!recovered||closed||lease.cancelled||sessionToken!==token||lanes.get(lane.id)!==lane)return;
}
}
if(!settleBatch(lease))return;port.postMessage({t:'traffic',up:lease.total,down:0});lane.sequence++;lease=null;
if(!lane.polling)pollLane(lane);
}
@@ -483,11 +430,12 @@ async function runLaneUp(lane){
finally{lane.running=false;if(!closed&&lanes.get(lane.id)===lane&&sessionToken&&lane.pending.length)runLaneUp(lane)}
}
async function pollLane(lane){
if(!lane||lane.polling)return;lane.polling=true;
if(!lane||lane.polling)return;lane.polling=true;let restart=false,failedToken='',failedCursor='',failedLaneID='';
try{
while(!closed&&sessionToken&&lanes.get(lane.id)===lane){
const controller=new AbortController(),laneID=String(lane.id);lane.controller=controller;
const response=await request('/api/v1/down',options('POST',sessionToken,null,{'X-Down-Cursor':lane.cursor,'X-Lane-ID':laneID},controller.signal));
const controller=new AbortController(),laneID=String(lane.id),token=sessionToken,cursor=lane.cursor;lane.controller=controller;
failedToken=token;failedCursor=cursor;failedLaneID=laneID;
const response=await request('/api/v1/down',options('POST',token,null,{'X-Down-Cursor':cursor,'X-Lane-ID':laneID},controller.signal),null,1);
if(response.status===204){
if(response.headers.get('X-Lane-Closed')==='1'){finishLane(lane,false);return}
status('connected');continue;
@@ -497,35 +445,55 @@ async function pollLane(lane){
if(!next||!data.byteLength)throw failure('protocol','invalid lane downlink response');
for(const value of splitFrames(data))if(value.id!==lane.id)throw new Error('cross-lane frame');
if(closed)return;
port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);lane.cursor=next;status('connected');
observeServerFrames(data);port.postMessage({t:'traffic',up:0,down:data.byteLength});port.postMessage(data,[data]);lane.cursor=next;status('connected');
}
}catch(error){if(!closed)fail(failureReason(error,'network'))}
finally{lane.polling=false;lane.controller=null}
}catch(error){
if(!closed&&lanes.get(lane.id)===lane){
const recovered=await recoverTransport(error,async(signal,remaining)=>{
const response=await request('/api/v1/down',options('POST',failedToken,null,{'X-Down-Cursor':failedCursor,'X-Lane-ID':failedLaneID},signal),remaining,2);
if(response.status===204)return;
if(response.status!==200||!response.body.byteLength||!response.headers.get('X-Down-Cursor'))throw failure('http','lane downlink replay rejected');
});
restart=recovered&&!closed&&sessionToken===failedToken&&lanes.get(lane.id)===lane;
}
}
finally{lane.polling=false;lane.controller=null;if(restart)pollLane(lane)}
}
function deleteSession(){
const token=cleanupToken||sessionToken,headers=canonicalFailures.includes(terminalFailure)?{'X-Carrier-Failure':terminalFailure}:null;
if(token)fetch(relayOrigin+'/api/v1/session',options('DELETE',token,null,headers,undefined,true)).catch(()=>{});
}
function close(notifyServer){
if(closed)return;closed=true;if(helloTimer)clearTimeout(helloTimer);helloTimer=null;if(carrierTimer)clearTimeout(carrierTimer);clearProbeTimer();if(attemptController)attemptController.abort();if(pollController)pollController.abort();
if(closed)return;closed=true;if(recoveryController)recoveryController.cancel();rejectRecoveryCommit(failure('network','bridge closed'));if(helloTimer)clearTimeout(helloTimer);helloTimer=null;if(carrierTimer)clearTimeout(carrierTimer);clearProbeTimer();if(attemptController)attemptController.abort();if(pollController)pollController.abort();
if(socket)socket.close();cancelBatch(upLease);releasePending(upPending,null);
for(const lane of lanes.values()){
if(lane.controller)lane.controller.abort();cancelBatch(lane.upLease);releasePending(lane.pending,lane);if(lane.socket)lane.socket.close();
}
if(notifyServer)deleteSession();releasePending(pending,null);lanes.clear();if(port)port.close();
if(queuedBytes!==0||queuedItems!==0)throw new Error('queue accounting leak');
if(notifyServer)deleteSession();releasePending(pending,null);releasePending(recoveryPending,null);lanes.clear();clearStreams();if(port)port.close();
buffers.assertEmpty();
}
function activatePort(nextPort){
initialized=true;port=nextPort;
port.onmessage=message=>{
observeResumeTrigger();
if(message.data instanceof ArrayBuffer){
if(!createStarted){createStarted=true;if(helloTimer)clearTimeout(helloTimer);helloTimer=null;helloFrame=message.data;if(negotiationEnabled){negotiationStartedAt=Date.now();armCarrierDeadline(attemptEpoch)}createSession(attemptEpoch)}
else if(!carrierCommitted){if(!reserve(message.data,null)){fail('capacity');return}pending.push(message.data);maybeStartCandidate()}
else queueCarrier(message.data);
else{
let data;try{data=acceptNativeFrames(message.data)}catch(error){fail(error&&error.telemtReason==='capacity'?'capacity':'protocol');return}if(!data)return;
if(recoveryController.active()&&!recoveryReplaced){if(!reserve(data,null)){fail('capacity');return}recoveryPending.push(data)}
else if(!carrierCommitted){if(!reserve(data,null)){fail('capacity');return}pending.push(data);maybeStartCandidate()}
else queueCarrier(data);
}
}else if(message.data&&message.data.t==='close'){status('failed','terminal','closed',0);close(true)}
};
port.start();status('connecting','starting','',bridgeRequestMs);helloTimer=setTimeout(()=>fail('timeout'),bridgeRequestMs);
}
recoveryController=recoverySupport.create({
budgetMs:()=>bridgeRecoveryMs,requestMs:()=>bridgeRequestMs,url:()=>relayOrigin+recoveryPath,token:()=>cleanupToken||sessionToken,
read:(response,limit,exact,signal)=>responseBody.read(response,limit,exact,signal),cancel:responseBody.cancel,status:remaining=>status('reconnecting','retrying','',remaining),
restored:finishOldRecovery,replace:replaceCarrier,replaceable:error=>failureReason(error,'network')!=='protocol',
reason:(error,fallback)=>failureReason(error,fallback),terminal:reason=>fail(recoveryController.remaining()<=0?'timeout':reason)
});
addEventListener('message',event=>{
if(event.source!==parent)return;if(initialized){if(event.ports&&event.ports.length===1)event.ports[0].close();return}
if(event.data===null||typeof event.data!=='object')return;
@@ -535,8 +503,7 @@ addEventListener('message',event=>{
if(source.protocol!=='http:'||source.hostname!=='127.0.0.1'||!source.port||source.origin!==event.origin)return;
activatePort(event.ports[0]);
},{once:false});
const androidBridge=globalThis.TelegramWebProxy;
if(!initialized&&androidNonce&&androidBridge&&typeof androidBridge.postMessage==='function'){
function activateAndroid(androidBridge){
const androidPort={onmessage:null,start(){},close(){androidBridge.onmessage=null},postMessage(value){
if(value instanceof ArrayBuffer){
let frames;try{frames=splitFrames(value)}catch(error){fail('protocol');return}
@@ -546,5 +513,16 @@ if(!initialized&&androidNonce&&androidBridge&&typeof androidBridge.postMessage==
androidBridge.onmessage=event=>{let data=event.data;if(typeof data==='string'){try{data=JSON.parse(data)}catch(error){return}}if(androidPort.onmessage)androidPort.onmessage({data})};
activatePort(androidPort);androidBridge.postMessage(JSON.stringify({t:'tproxy-android-init',v:1,nonce:androidNonce}));
}
function discoverAndroid(){
if(!androidNonce)return;const wall=Date.now()+bridgeRequestMs,monotonic=performance.now()+bridgeRequestMs;
const probe=()=>{
if(initialized||closed)return;const androidBridge=globalThis.TelegramWebProxy;
if(androidBridge&&typeof androidBridge.postMessage==='function'){activateAndroid(androidBridge);return}
const remaining=Math.min(wall-Date.now(),monotonic-performance.now());if(remaining>0)setTimeout(probe,Math.min(100,remaining));
};probe();
}
discoverAndroid();
addEventListener('online',observeResumeTrigger);
if(globalThis.document&&typeof globalThis.document.addEventListener==='function')globalThis.document.addEventListener('visibilitychange',()=>{if(globalThis.document.visibilityState==='visible')observeResumeTrigger()});
addEventListener('pagehide',()=>fail('navigation'),{once:true});
})();