Page: catch background fetches, no HTML in errors, a trace ends after a quiet minute (#219)
The page reported NetworkError rejections from the live update's list refresh and the first feed load, which had no catch, whenever the network was gone (a laptop asleep). A Cloudflare 502 page became a request's error message, since api() used whatever body came back. And the trace id lived until the next tap, so background requests nine hours apart shared one trace. The id now ends after a minute without a request; a play's position saves, every ten seconds, keep it going. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,18 @@ for (const [name, fn] of drive) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A trace is one tap's (#219): the same id while requests keep coming, a new one after a quiet
|
||||||
|
// minute, so a refresh hours later is not filed under the last tap.
|
||||||
|
if (PAGE === 'index.html') {
|
||||||
|
const id = () => ctx.traceparent().split('-')[1];
|
||||||
|
vm.runInContext('Date.now = (n => () => n)(1e12)', ctx);
|
||||||
|
const first = id();
|
||||||
|
vm.runInContext('Date.now = (n => () => n)(1e12 + 30_000)', ctx);
|
||||||
|
if (id() !== first) { console.error('FAIL: a trace ended 30 s into a tap'); process.exit(1); }
|
||||||
|
vm.runInContext('Date.now = (n => () => n)(1e12 + 120_000)', ctx);
|
||||||
|
if (id() === first) { console.error('FAIL: a trace outlived a quiet minute'); process.exit(1); }
|
||||||
|
}
|
||||||
|
|
||||||
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
|
// theme.ts keeps the Settings theme controls in step when Settings is open, and looks before it
|
||||||
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
|
// touches them. The admin page has no Settings, so those are the ones it may ask for and not find.
|
||||||
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
|
const OPTIONAL = new Set(['#stheme', '#smode', '#smodefield']);
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A live update or a list refresh that fails because the network is gone (a laptop asleep) is no longer reported as an error, an error page from Cloudflare no longer becomes a request's message, and requests a minute or more after a tap start a trace of their own instead of joining that tap's (#219).
|
||||||
|
|
||||||
## [1.0.0] - 2026-10-12
|
## [1.0.0] - 2026-10-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ function connect(){
|
|||||||
// Back after a drop: whatever was sent meanwhile went to nobody.
|
// Back after a drop: whatever was sent meanwhile went to nobody.
|
||||||
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } };
|
||||||
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
const soon=(fn,ms=500)=>{ let t; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
|
||||||
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(); });
|
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
|
||||||
|
const refreshEntries=soon(()=>{ if(S.feed) loadEntries().catch(()=>{}); });
|
||||||
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
|
||||||
const mine=id=>S.feeds.some(f=>f.id===id);
|
const mine=id=>S.feeds.some(f=>f.id===id);
|
||||||
sse.onmessage=m=>{
|
sse.onmessage=m=>{
|
||||||
@@ -70,4 +71,4 @@ function connect(){
|
|||||||
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
|
||||||
}
|
}
|
||||||
connect();
|
connect();
|
||||||
loadFeeds();
|
loadFeeds().catch(()=>{});
|
||||||
|
|||||||
@@ -72,11 +72,18 @@ function spanText(secs){
|
|||||||
}
|
}
|
||||||
/// One trace for each thing you do (#190): the requests a tap or a key sets off share a trace id,
|
/// One trace for each thing you do (#190): the requests a tap or a key sets off share a trace id,
|
||||||
/// sent as traceparent, so Tempo shows them together. getRandomValues, not randomUUID, which a
|
/// sent as traceparent, so Tempo shows them together. getRandomValues, not randomUUID, which a
|
||||||
/// LAN address over plain HTTP does not have.
|
/// LAN address over plain HTTP does not have. A trace ends after a minute with no request in it:
|
||||||
let traceId='';
|
/// the requests a live update or a woken laptop sets off hours later used to join the last tap's
|
||||||
|
/// trace, which then lasted nine hours (#219). A play's position saves, every ten seconds, keep it.
|
||||||
|
let traceId='', traceAt=0;
|
||||||
const hex=(n: number)=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
|
const hex=(n: number)=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
|
||||||
for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); },{capture:true,passive:true});
|
for(const t of ['pointerdown','keydown']) window.addEventListener(t,()=>{ traceId=hex(16); traceAt=Date.now(); },{capture:true,passive:true});
|
||||||
function traceparent(){ return `00-${traceId||=hex(16)}-${hex(8)}-01`; }
|
function traceparent(){
|
||||||
|
const now=Date.now();
|
||||||
|
if(!traceId||now-traceAt>60_000) traceId=hex(16);
|
||||||
|
traceAt=now;
|
||||||
|
return `00-${traceId}-${hex(8)}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
/// Something that went wrong, or how long something took, in the browser, for the server's log
|
/// Something that went wrong, or how long something took, in the browser, for the server's log
|
||||||
/// (#190). The same thing at most once a minute: a failure in a loop would otherwise send a line
|
/// (#190). The same thing at most once a minute: a failure in a loop would otherwise send a line
|
||||||
@@ -95,7 +102,9 @@ window.addEventListener('unhandledrejection',e=>report('rejection',{msg:String(e
|
|||||||
async function api(url: string, opts?: RequestInit): Promise<any>{
|
async function api(url: string, opts?: RequestInit): Promise<any>{
|
||||||
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
|
const r = await fetch(url,{headers:{'Content-Type':'application/json',traceparent:traceparent()},...opts});
|
||||||
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
||||||
if(!r.ok) throw new Error(await r.text().catch(()=>'')||String(r.status));
|
// A page in place of ipx's words is Cloudflare or Caddy answering for it: the message became a
|
||||||
|
// whole 502 page (#219).
|
||||||
|
if(!r.ok) throw new Error((!/html/.test(r.headers.get('Content-Type')||'')&&await r.text().catch(()=>''))||`${r.status} ${r.statusText}`.trim());
|
||||||
return r.status===204?null:r.json().catch(()=>null);
|
return r.status===204?null:r.json().catch(()=>null);
|
||||||
}
|
}
|
||||||
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
// navigator.clipboard only exists in a secure context. Served over plain HTTP on a LAN
|
||||||
|
|||||||
Reference in New Issue
Block a user