PageSourceSearch

https://home.funkystadium.app/shared/api-post.js?v=136-1042

js funkystadium.app collected 2026-10-05 08:47:12 UTC 11,149 bytes, 227 lines download raw bytes

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.