(path,opts={})
| 1 | async function api(path,opts={}){ |
| 2 | // Strip leading slash so URL resolves relative to location.href (supports subpath mounts) |
| 3 | const rel = path.startsWith('/') ? path.slice(1) : path; |
| 4 | const url=new URL(rel,document.baseURI||location.href); |
| 5 | const timeoutMs=Object.prototype.hasOwnProperty.call(opts,'timeoutMs')?opts.timeoutMs:30000; |
| 6 | const timeoutToast=opts.timeoutToast!==false; |
| 7 | const redirect401=opts.redirect401!==false; |
| 8 | const maxAttempts=Object.prototype.hasOwnProperty.call(opts,'retries')?Math.max(0,Number(opts.retries)||0)+1:3; |
| 9 | const retryTimeouts=opts.retryTimeouts===true; |
| 10 | const retryStatuses=Array.isArray(opts.retryStatuses)?opts.retryStatuses.map(Number).filter(Number.isFinite):[]; |
| 11 | const retryDelayMs=Object.prototype.hasOwnProperty.call(opts,'retryDelayMs')?Math.max(0,Number(opts.retryDelayMs)||0):350; |
| 12 | // Retry up to 2 times on network errors (e.g. stale keep-alive after long idle). |
| 13 | // Callers may opt into retrying timeouts / transient server statuses for idempotent GETs. |
| 14 | let lastErr; |
| 15 | for(let attempt=0;attempt<maxAttempts;attempt++){ |
| 16 | let controller=null; |
| 17 | let timeoutId=null; |
| 18 | let didTimeout=false; |
| 19 | let upstreamSignal=null; |
| 20 | let upstreamAbort=null; |
| 21 | try{ |
| 22 | const fetchOpts={...opts}; |
| 23 | delete fetchOpts.timeoutMs; |
| 24 | delete fetchOpts.timeoutToast; |
| 25 | delete fetchOpts.redirect401; |
| 26 | delete fetchOpts.retries; |
| 27 | delete fetchOpts.retryTimeouts; |
| 28 | delete fetchOpts.retryStatuses; |
| 29 | delete fetchOpts.retryDelayMs; |
| 30 | |
| 31 | const useTimeout=Number.isFinite(Number(timeoutMs))&&Number(timeoutMs)>0; |
| 32 | if(useTimeout&&typeof AbortController!=='undefined'){ |
| 33 | controller=new AbortController(); |
| 34 | upstreamSignal=fetchOpts.signal||null; |
| 35 | if(upstreamSignal){ |
| 36 | upstreamAbort=()=>controller.abort(upstreamSignal.reason); |
| 37 | if(upstreamSignal.aborted) upstreamAbort(); |
| 38 | else upstreamSignal.addEventListener('abort',upstreamAbort,{once:true}); |
| 39 | } |
| 40 | fetchOpts.signal=controller.signal; |
| 41 | } |
| 42 | const requestPromise=(async()=>{ |
| 43 | const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...fetchOpts}); |
| 44 | if(!res.ok){ |
| 45 | // 401 means the auth session expired. Redirect to login so the user can |
| 46 | // re-authenticate. This is especially important for iOS PWA (standalone mode) |
| 47 | // and for subpath mounts like /hermes/, where /login escapes to the site root. |
| 48 | if(res.status===401){ |
| 49 | if(redirect401) window.location.href='login?next='+encodeURIComponent(window.location.pathname+window.location.search); |
| 50 | // Callers can opt out of navigation and handle the unauthenticated state themselves. |
| 51 | return; |
| 52 | } |
| 53 | const text=await res.text(); |
| 54 | // Parse JSON error body and surface the human-readable message, |
| 55 | // rather than showing raw JSON like {"error":"Profile 'x' does not exist."} |
| 56 | let message=text; |
| 57 | try{const j=JSON.parse(text);message=j.error||j.message||text;}catch(e){} |
| 58 | // Attach the raw HTTP context so callers can branch on status (404 stale-session |
| 59 | // cleanup, 401 redirect, 503 retry, etc.) without re-parsing the message string. |
| 60 | const err=new Error(message); |
no test coverage detected