1// AUTO-GENERATED â DO NOT EDIT 2// Source: shared/api-post.js 3// Regenerated by scripts/sync-workshop-modules.mjs (apps/home Hosting target â workshop reception console). 4// Hand-edits to this file will be overwritten on the next deploy. 5 6// shared/api-post.js â v136.550. 7// 8// ONE authenticated POST to an /api/ Cloud Function, under ONE deadline that 9// covers the WHOLE operation. This is the shared primitive the v136.549 fix 10// should have been from the start. 11// 12// ââ WHY THIS EXISTS âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 13// v136.549 fixed a hang in two client helpers and its DEPLOY doc called it "a 14// class of two". That was wrong. A categorical sweep found 67 client `/api/` 15// fetch sites, of which only 5 have a deadline that actually covers the 16// operation. The rest can hang forever. 17// 18// Three awaits can each hang, and a timeout on only the middle one is not a 19// timeout: 20// 1. `user.getIdToken()` â usually a cache hit, but on an expired token it 21// makes a network refresh. Arming an AbortController *before* it does not 22// help: a hang there never reaches the fetch the controller would abort. 23// 2. `fetch()` â the one everybody remembers. 24// 3. `res.json()` â and this is the subtle one: clearing the timer in the 25// fetch's `.then` fires when the response HEADERS land, not the body. A 26// server that sends headers then stalls hangs the body read with the abort 27// signal already disarmed. 28// 29// A promise that never settles is worse than a rejected one: no fallback runs, 30// no error toast appears, no optimistic state is reverted â and where the 31// promise is handed to a UI guard (shared/ui-guards.js `lockedAction`, whose 32// `.finally()` never runs), the control stays locked and silently swallows every 33// later tap for the rest of the session. 34// 35// ââ THE DROP-IN CONTRACT ââââââââââââââââââââââââââââââââââââââââââââââââââââ 36// Resolves to a Response-LIKE object, not a Response: the body is read inside 37// the deadline and cached, so `.json()` is synchronous-ish and cannot hang. 38// `{ ok, status, statusText, json(), text() }` â which means an existing 39// `_authedPost(...).then(r => r.json())` caller works unchanged while gaining 40// full coverage. That is deliberate: 67 sites is too many to fix by rewriting 41// every caller, and a migration that changes the caller contract would not get 42// finished. 43// 44// Rejects with a tagged Error on timeout (`err.isTimeout === true`, 45// `err.name === 'AbortError'` so existing AbortError branches still match). 46// 47// And with `err.notSent === true` when the rejection happened BEFORE any fetch â 48// see _tagNotSent. A caller offering a retry needs "we never asked" told apart 49// from "we gave up mid-flight and the server may have it"; the absence of the 50// flag is the pessimistic (may-have-been-sent) reading. 51(function (root) { 52 'use strict'; 53 54 var DEFAULT_TIMEOUT_MS = 30000; 55 56 function _timeoutError(ms) { 57 var e = new Error('timeout after ' + ms + 'ms'); 58 e.name = 'AbortError'; // existing callers branch on this 59 e.isTimeout = true; 60 return e; 61 } 62 63 // v136.963 review MED-1 â TAG AT THE SOURCE. Only the thrower knows whether a 64 // request left the machine, and the difference is what a caller has to tell the 65 // user. Every throw below happens BEFORE `fetch` is ever called (the token 66 // refresh, and the no-signed-in-user case), so nothing was sent; a rejection 67 // WITHOUT this flag may have reached the server and completed there. 68 // 69 // Why it matters concretely: shared/access-request.js used to infer "the POST 70 // was attempted" from "the promise rejected" and appended ãç³è«ã¯æ¢ã«éä¿¡ããã¦ãã 71 // å¯è½æ§ãããã¾ããâ check with an admin before retrying. `forceRefresh: true` 72 // always hits the network, so on an OFFLINE operator the token refresh is the 73 // FIRST thing that fails: the single most likely failure of that flow told a 74 // denied operator to go ask an admin about a request that does not exist. 75 // 76 // Best-effort: a frozen or non-object throw simply carries no flag, and the 77 // reader treats an untagged throw as "may have been sent" â the safe direction. 78 function _tagNotSent(e) { 79 try { if (e && typeof e === 'object') e.notSent = true; } catch (_) { /* frozen */ } 80 return e; 81 } 82 83 /** 84 * v136.557 â a deadline over a COMPOSITE operation. 85 * 86 * `apiPost` covers one POST. Some client operations are several awaits that 87 * must share ONE budget: `shared/admin-auth.js` `callRefresh` is 88 * getIdToken(force) â POST refreshMyAdminClaim â maybe getIdToken(force) â 89 * maybe POST refreshMyStaffClaim. Giving each POST its own 30s deadline 90 * would stack to 60s+ and still leave the two token refreshes bare â so the 91 * budget has to sit above the whole chain, not inside each step. 92 * 93 * Same tagged rejection as `apiPost` (`isTimeout`, `name:'AbortError'`) so a 94 * caller can branch identically whichever primitive produced it. 95 * 96 * NOTE this bounds the CALLER's wait, not the server's work. It is only safe 97 * where a retry is harmless â an idempotent grant, a read, a verify. Do NOT 98 * wrap a non-idempotent write in it and then offer the user a retry button: 99 * the request may have COMPLETED server-side while the client gave up. 100 * 101 * @param {Promise} promise 102 * @param {number} ms 103 */ 104 function withDeadline(promise, ms) { 105 var n = (ms === undefined || ms === null) ? DEFAULT_TIMEOUT_MS : Number(ms); 106 if (!isFinite(n) || n <= 0) n = DEFAULT_TIMEOUT_MS; 107 var timer = null; 108 var deadline = new Promise(function (_resolve, reject) { 109 timer = setTimeout(function () { reject(_timeoutError(n)); }, n); 110 }); 111 return Promise.race([Promise.resolve(promise), deadline]).then( 112 function (v) { if (timer) clearTimeout(timer); return v; }, 113 function (e) { if (timer) clearTimeout(timer); throw e; } 114 ); 115 } 116 117 /** 118 * @param {string} url e.g. '/api/applyTapOp' 119 * @param {object} bodyObj JSON-serialised as the request body 120 * @param {{timeoutMs?:number, auth?:boolean, forceRefresh?:boolean, 121 * requireAuth?:boolean, headers?:object}} [opts] 122 * auth â attach a Bearer ID token when a user is signed in (default true) 123 * requireAuth â reject rather than send anonymously when there is no user 124 * forceRefresh â pass true to getIdToken (only where a just-minted claim is needed) 125 * @returns {Promise<{ok:boolean,status:number,statusText:string, 126 * json:function,text:function,headers:Headers|null}>} 127 */ 128 function apiPost(url, bodyObj, opts) { 129 opts = opts || {}; 130 // v136.552 â an explicit 0 is a plausible "no deadline" idiom and used to be 131 // silently promoted to 30s by `||`. Treat only undefined/null as "unset". 132 var ms = (opts.timeoutMs === undefined || opts.timeoutMs === null) 133 ? DEFAULT_TIMEOUT_MS : Number(opts.timeoutMs); 134 if (!isFinite(ms) || ms <= 0) ms = DEFAULT_TIMEOUT_MS; 135 136 // v136.557 â the race lives in withDeadline now, so the two primitives 137 // cannot drift apart. `ms` is already resolved here (the explicit-0 rule 138 // above); withDeadline re-resolving a valid positive number is a no-op. 139 return withDeadline(_run(url, bodyObj, opts, ms), ms); 140 } 141 142 // `ms` is passed in already resolved â ONE source of truth for the deadline. 143 // v136.552: this used to recompute `opts.timeoutMs || DEFAULT_TIMEOUT_MS` 144 // independently, so a caller override could disagree with the outer race. 145 async function _run(url, bodyObj, opts, ms) { 146 var headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {}); 147 148 if (opts.auth !== false) { 149 var user = null;
150 try { 151 user = (root.firebase && root.firebase.auth) ? root.firebase.auth().currentUser : null; 152 } catch (_) { user = null; } 153 if (user && typeof user.getIdToken === 'function') { 154 // Inside the deadline â this is await #1, and it is the one every 155 // hand-rolled AbortController misses. 156 try { 157 var token = await user.getIdToken(opts.forceRefresh === true); 158 if (token) headers['Authorization'] = 'Bearer ' + token; 159 } catch (e) { 160 // Token fetch failed (not hung). Preserve the long-standing behaviour 161 // of the two _authedPost twins: fall through and send anonymously, 162 // letting the CF's own 401 be the answer â UNLESS the caller says the 163 // request is meaningless without auth. 164 if (opts.requireAuth) { _tagNotSent(e); throw e; } 165 } 166 } else if (opts.requireAuth) { 167 throw _tagNotSent(new Error('not_signed_in')); 168 } 169 } 170 171 // The AbortController still cancels the in-flight request on timeout so the 172 // socket does not linger; the outer race is what guarantees settlement. 173 var ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null; 174 var abortTimer = ctl ? setTimeout(function () { ctl.abort(); }, ms) : null; 175 176 try { 177 var res = await fetch(url, { 178 method: 'POST', 179 mode: 'cors', 180 headers: headers, 181 body: JSON.stringify(bodyObj || {}), 182 signal: ctl ? ctl.signal : undefined, 183 }); 184 185 // await #3 â read the body INSIDE the deadline, then hand back a 186 // Response-like whose .json() cannot hang. 187 // 188 // v136.552 â do NOT swallow a body-read failure. The first version did 189 // (`catch (_) { raw = ''; }`), which turned a mid-body connection reset on 190 // a 200 response into `{}` â `data.ok` falsy â the caller reports failure 191 // for an operation that SUCCEEDED. On the reception refund path that means 192 // telling an operator holding cash that a completed refund failed. Only an 193 // abort â the deadline we ourselves fired â is allowed to yield an empty 194 // body; everything else rejects, which is what the callers' .catch is for. 195 var raw = ''; 196 try { 197 raw = await res.text(); 198 } catch (e) { 199 if (ctl && ctl.signal && ctl.signal.aborted) raw = ''; 200 else throw e; 201 } 202 203 return { 204 ok: res.ok, 205 status: res.status, 206 statusText: res.statusText, 207 headers: res.headers || null, 208 text: function () { return Promise.resolve(raw); }, 209 json: function () { 210 try { return Promise.resolve(raw ? JSON.parse(raw) : {}); } 211 catch (e) { return Promise.reject(e); } 212 }, 213 }; 214 } finally { 215 if (abortTimer) clearTimeout(abortTimer); 216 } 217 } 218 219 root.SHIROFES_ApiPost = { 220 apiPost: apiPost, 221 withDeadline: withDeadline, 222 DEFAULT_TIMEOUT_MS: DEFAULT_TIMEOUT_MS, 223 }; 224 if (typeof module !== 'undefined' && module.exports) { 225 module.exports = root.SHIROFES_ApiPost; 226 } 227})(typeof window !== 'undefined' ? window : globalThis);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.