The demo account — one shared user that whitelisted emails entered from the login page, reset on a schedule — goes end to end (owner decision, E34): demo.py and the reset job, users.is_demo and demo_whitelist (migration 0098 deletes the demo user, its audit rows, the reset job's override and notices, then drops the column and table; the downgrade restores the empty schema), the /auth/demo entry, the admin whitelist and reset, the Demo tab, the banner, the login page's "Try the demo", and every is_demo branch in routes, userscope, scheduler and the UI. require_human only ever refused the demo account, so its 87 uses become current_user (owner-approved); require_write_scope keeps its own check. The login page's "or" divider now shows only beside the Google button: without the demo entry under it, an instance without Google would have said "or" to nothing. A contract test (removedFeatures) fails on the word anywhere in the backend, frontend, e2e or scripts — seen red on a planted hit. Size ceilings tightened for the modules that shrank; admin.py is back under the limit.
483 lines
25 KiB
TypeScript
483 lines
25 KiB
TypeScript
// The shared HTTP machinery behind every api.* domain module: the fetch wrapper (`req`), the typed
|
||
// error, structured-error localization, the connectivity toast, the per-tab active-account plumbing,
|
||
// and the keepalive beacon. Domain modules import from here; nothing here imports a domain module,
|
||
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
||
import { getActiveToasts, notify, remove } from "../notifications";
|
||
import i18n from "../../i18n";
|
||
import { formatBytes, formatDecimal } from "../format";
|
||
import { reportError } from "../errorDialog";
|
||
import { activeAccountId, pinActiveAccount } from "../activeAccount";
|
||
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
||
import { currentDebugSession, newRequestId } from "../log";
|
||
|
||
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
|
||
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
|
||
* every caller is one every caller can forget, which is how the registration language shipped as a
|
||
* field nobody ever filled (E9.5 S6 → S7). */
|
||
export const uiLang = (): string => i18n.language.split("-")[0] || "en";
|
||
|
||
// Errors `req` has already put in front of the user for good — the blocking error dialog, or the
|
||
// sign-out a 401 triggers. A caller that toasts a failed action as well reported the same fault twice
|
||
// (a modal AND a toast); `notifyActionError` asks this instead of each caller filtering statuses by
|
||
// hand.
|
||
//
|
||
// NOT the connection notice. It says the server is unreachable, not that this action failed, and it
|
||
// removes itself on the next answer — which every caller's own resync refetch fetches at once. Marked
|
||
// as reported, a delete that hit a keepalive reset (a 502) flashed the notice for a moment and was
|
||
// then reported nowhere at all, the list still there (deep-review round 4).
|
||
//
|
||
// A caller that reports a failure itself marks it too (`notifyActionError` does), which is what lets
|
||
// the ONE app-wide mutation reporter (lib/queryClient) stay quiet for a mutation whose own `onError`
|
||
// already said something, and speak for every one that did not.
|
||
const reported = new WeakSet<object>();
|
||
export function alreadyReported(err: unknown): boolean {
|
||
return typeof err === "object" && err !== null && reported.has(err);
|
||
}
|
||
/** Record that this failure is on screen now, so nothing reports it a second time. */
|
||
export function markReported(err: unknown): void {
|
||
if (typeof err === "object" && err !== null) reported.add(err);
|
||
}
|
||
|
||
/** What an upload the USER cancelled rejects with. A cancel is not a failure to report, and the
|
||
* only other rejection carrying status 0 is a genuine network drop, which IS worth reporting — so
|
||
* the difference has to be a named thing both sides agree on, not a string literal in each. */
|
||
const ABORTED = "aborted";
|
||
|
||
/** True for the rejection of a request the user cancelled (see `ABORTED`). */
|
||
export function isAborted(err: unknown): boolean {
|
||
return err instanceof HttpError && err.status === 0 && err.detail === ABORTED;
|
||
}
|
||
|
||
export class HttpError extends Error {
|
||
status: number;
|
||
// The server's reason IN THE READER'S LANGUAGE, when it gave one — i.e. a structured detail run
|
||
// through `localizeDetail`, never the raw text of a string detail. Every refusal the app answers is
|
||
// structured (backend `app/refusal.py`, enforced by its test_http_details.py); a string can only be
|
||
// the framework's own ("Method Not Allowed"), so it is left out rather than shown. That is what
|
||
// makes reading `.detail` safe anywhere — including destructured, which no source scan could see.
|
||
detail?: string;
|
||
detailData?: unknown; // structured detail ({ code, reason, ... }) when the server sent an object
|
||
constructor(status: number, detail?: string, detailData?: unknown) {
|
||
super(detail || `HTTP ${status}`);
|
||
this.status = status;
|
||
this.detail = detail;
|
||
this.detailData = detailData;
|
||
}
|
||
}
|
||
|
||
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
|
||
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
|
||
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
|
||
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
|
||
// The refusal codes of the admin's account and invite actions (routes/admin.py). Their reasons share
|
||
// one namespace, `errors.admin.<reason>`, which i18n/backendIds.contract checks against the backend.
|
||
const ADMIN_REFUSALS = new Set(["activate", "resend", "invite_send"]);
|
||
|
||
function localizeDetail(d: unknown): string {
|
||
const t = i18n.t.bind(i18n);
|
||
// FastAPI request-validation errors: `detail` is an ARRAY of { loc, msg, type }. The UI sends
|
||
// well-formed requests, so this only fires on genuinely malformed input. The per-field `msg` and
|
||
// `loc` field names are English, snake_case internal tokens — surface a clean, fully localized
|
||
// "check your input" message rather than leaking them into a translated sentence.
|
||
if (Array.isArray(d)) return t("errors.validation");
|
||
const o = d as
|
||
| { code?: string; reason?: string; limit?: number; min?: number }
|
||
| null
|
||
| undefined;
|
||
if (o?.code === "quota") {
|
||
if (o.reason === "max_bytes") {
|
||
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
|
||
return t("downloads.errors.quota.max_bytes", { size: gb });
|
||
}
|
||
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
|
||
return t("downloads.errors.quota.generic");
|
||
}
|
||
if (o?.code === "edit")
|
||
return t(`downloads.errors.edit.${o.reason}`, t("downloads.errors.edit.generic"));
|
||
if (o?.code === "cookies")
|
||
return t(`downloads.errors.cookies.${o.reason}`, t("downloads.errors.cookies.generic"));
|
||
// Password sign-in refusals (E9.5 S4). The card renders the account-shaped ones as a full panel
|
||
// off `detailData`; this is the one-line form every other consumer (and the card's own fallback)
|
||
// gets. A reason this build doesn't know falls through to the generic message rather than showing
|
||
// a raw key, which is what an i18n miss would otherwise put on screen.
|
||
if (o?.code === "login")
|
||
return t(`welcome.auth.blocked.${o.reason}.short`, t("welcome.auth.genericError"));
|
||
// The two email-link confirmations. Their failure is one a user meets by simply clicking an old
|
||
// link, so it is the last place that may answer in hardcoded English under a bilingual UI.
|
||
if (o?.code === "verify") return t("welcome.auth.verifyInvalid");
|
||
if (o?.code === "reset")
|
||
return o.reason === "weak_password"
|
||
? t("welcome.auth.weakPassword", { min: o.limit ?? o.min })
|
||
: t("welcome.auth.deadLink.short");
|
||
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
|
||
if (o?.code === "upload_too_large")
|
||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
|
||
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
||
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
|
||
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
|
||
// because the ceiling and the route's own cap are the same figure and the edge answers first —
|
||
// the route's numbered `upload_too_large` above is the answer only once an admin has LOWERED the
|
||
// knob (see bodylimit.ROUTE_MAX_BODY_BYTES). Without this branch that refusal fell through to
|
||
// the generic "couldn't carry out that action", the one message no retry can fix.
|
||
//
|
||
// A BACKSTOP, not the plan. The middleware answers without draining, so whether the browser gets
|
||
// to read this at all depends on the client having finished sending: measured readable up to
|
||
// 200 MiB over loopback, and measured as a bare `onerror` through a dev proxy. That is why the
|
||
// upload dialog checks the file against `upload_max_bytes` BEFORE sending — this branch is what
|
||
// happens when that check could not run.
|
||
//
|
||
// `errors.tooLarge` stays PAYLOAD-NEUTRAL for that reason. It is also what a JSON form gets
|
||
// (`debug_sessions` refuses an over-size draft with the same bare code), so wording it as advice
|
||
// about a file told the issue composer's user to pick a smaller one — of nothing. The
|
||
// file-specific sentence is `downloads.errors.upload.tooLarge`, one branch up, which has a number.
|
||
if (o?.code === "too_large") return t("errors.tooLarge");
|
||
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
|
||
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
|
||
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
|
||
if (o?.code === "url_blocked")
|
||
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
|
||
// E10 S2: the one-step "move to another list". Both reasons mean the same thing to a reader —
|
||
// the list moved under you — but they are different enough to be worth naming separately, and an
|
||
// unknown reason falls back to the plain failure rather than printing a key.
|
||
if (o?.code === "playlist_move")
|
||
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
|
||
// A Plex collection name already taken in that library (the one collection-edit refusal).
|
||
if (o?.code === "collection")
|
||
return o.reason === "name_taken"
|
||
? t("plex.collection.nameTaken")
|
||
: t("plex.collection.editFailed");
|
||
// An action YouTube refused (routes/_common.youtube_refusal): what the reader can do about it —
|
||
// wait for the budget, reconnect, or retry. An unknown reason reads as the retryable one.
|
||
if (o?.code === "youtube")
|
||
return t(`errors.youtube.${o.reason}`, t("errors.youtube.unavailable"));
|
||
// Every other refusal (backend `app/refusal.py`): one family, the reason naming the sentence and
|
||
// the rest of the detail its values (`{{min}}`, `{{name}}`, …). A reason this build does not know
|
||
// reads as the generic failure rather than a key.
|
||
if (o?.code === "refused")
|
||
return t(`errors.refused.${o.reason}`, { ...o, defaultValue: t("errors.generic") });
|
||
// A playlist action the server refused (Watch later can't be deleted, YouTube said no, …).
|
||
if (o?.code === "playlist") return t(`playlists.errors.${o.reason}`, t("errors.generic"));
|
||
// An admin action on an account or an invite that the server refused: activating an address that
|
||
// is no longer approved, a confirmation link or invitation with nothing to go to or no mail to go
|
||
// by. The toast's title already names the button; this is the WHY, and it used to be the generic
|
||
// "try again", which no retry could fix.
|
||
if (ADMIN_REFUSALS.has(o?.code ?? "")) return t(`errors.admin.${o?.reason}`, t("errors.generic"));
|
||
// Any row that is gone — deleted in another tab or device, or never the caller's (a stranger's
|
||
// row 404s the same way). `reason` names WHAT is gone; one this build doesn't know says it
|
||
// generically rather than printing a key.
|
||
if (o?.code === "not_found")
|
||
return t(`errors.notFound.${o.reason}`, t("errors.notFound.generic"));
|
||
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
|
||
// limit, and the copy says so.
|
||
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
|
||
return t("errors.generic");
|
||
}
|
||
|
||
// A single, self-resolving "connection lost" status: while the server is unreachable we
|
||
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
|
||
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
|
||
// bursts of concurrent failures from stacking up — there's only ever one live handle.
|
||
let connectivityNotifId: number | null = null;
|
||
function markConnectivityLost(): void {
|
||
// One live notice — but a notice the user closed is not live: it used to swallow every later
|
||
// "connection lost" for the rest of the visit.
|
||
const id = connectivityNotifId;
|
||
if (id !== null && getActiveToasts().some((n) => n.id === id)) return;
|
||
if (id !== null) remove(id);
|
||
connectivityNotifId = notify({
|
||
level: "error",
|
||
title: i18n.t("errors.offline.title"),
|
||
message: i18n.t("errors.offline.body"),
|
||
transient: true,
|
||
});
|
||
}
|
||
function markConnectivityRestored(): void {
|
||
if (connectivityNotifId === null) return;
|
||
remove(connectivityNotifId);
|
||
connectivityNotifId = null;
|
||
}
|
||
|
||
// Gateway statuses that mean "the upstream connection failed", not "the app rejected the
|
||
// request" — typically a reverse-proxy ↔ uvicorn keepalive connection reset before the
|
||
// request was processed. Safe to retry an idempotent call once on a fresh connection.
|
||
const RETRIABLE_GATEWAY = new Set([502, 503, 504]);
|
||
const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms));
|
||
|
||
// What a failed answer puts on screen — ONE policy for `req` and `uploadReq` (the upload path kept
|
||
// its own copy, and it had drifted: a 502 there opened the blocking "Server error" dialog and
|
||
// cleared a connection notice that was still true).
|
||
//
|
||
// 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset), not a
|
||
// request the app refused — the soft, self-clearing connection notice rather than a blocking modal,
|
||
// and still the caller's to report as a failed action (see `reported`). A genuine 500 (real fault,
|
||
// carries a JSON detail) gets the modal, as do 400/409/422 (validation/conflict). 401 is the app
|
||
// shell's; 403/404/429 are caller-handled control flow.
|
||
function reportFailure(err: HttpError, quiet: boolean): void {
|
||
if (RETRIABLE_GATEWAY.has(err.status)) {
|
||
markConnectivityLost();
|
||
} else if (err.status === 401) {
|
||
// Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the app
|
||
// shell decide what to do (drop to the login page if we were signed in). Not a modal — but
|
||
// handled all the same, so not the caller's to toast either.
|
||
onUnauthorized?.();
|
||
reported.add(err);
|
||
} else if (quiet) {
|
||
/* caller handles the error inline — no global modal */
|
||
} else if (err.status >= 500) {
|
||
reportError(err.detail || `${i18n.t("errors.server")} (${err.status})`);
|
||
reported.add(err);
|
||
} else if (err.status === 400 || err.status === 409 || err.status === 422) {
|
||
reportError(err.detail);
|
||
reported.add(err);
|
||
}
|
||
}
|
||
|
||
// `idempotent`: may this request be replayed after a transient gateway/network failure?
|
||
// GETs always can; non-GET callers opt in (e.g. the player's progress/state writes, which
|
||
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
|
||
export interface ReqConfig {
|
||
idempotent?: boolean;
|
||
// Return the `Response` itself instead of parsed JSON — for a file (see the return below).
|
||
raw?: boolean;
|
||
// Suppress the global error modal for 4xx/5xx so the caller can show the error inline
|
||
// (used by the auth forms — a wrong password shouldn't pop a blocking dialog).
|
||
quiet?: boolean;
|
||
}
|
||
|
||
// Set by the app shell. Invoked whenever any request returns 401 so a session that ended
|
||
// server-side (account suspended/deleted while the tab was open) can drop the user to the login
|
||
// page. The handler itself guards against firing when we were never signed in, so it's safe to
|
||
// call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null
|
||
// user when logged out — but other protected endpoints still do.)
|
||
let onUnauthorized: (() => void) | null = null;
|
||
export function setUnauthorizedHandler(fn: (() => void) | null): void {
|
||
onUnauthorized = fn;
|
||
}
|
||
|
||
// Headers for install-wizard calls: the one-time setup token plus the JSON content type (req
|
||
// replaces — not merges — its default headers when opts.headers is given, so include both).
|
||
export const setupHeaders = (token: string) => ({
|
||
"Content-Type": "application/json",
|
||
"X-Setup-Token": token,
|
||
});
|
||
|
||
// --- Per-tab active account --------------------------------------------------------------
|
||
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts
|
||
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
|
||
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
|
||
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
|
||
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
|
||
// The debug session this tab is recording (E16): the server tags its own lines of the request with
|
||
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
|
||
const DEBUG_SESSION_HEADER = "X-Debug-Session";
|
||
|
||
export const getActiveAccount = activeAccountId;
|
||
|
||
/** Who this request is from — the tab's account and the debug session it records into — as the
|
||
* headers every request path sends (`req`, `beacon`, `uploadReq`): one definition, so a path
|
||
* cannot quietly go out without them. */
|
||
export function identityHeaders(): Record<string, string> {
|
||
const active = getActiveAccount();
|
||
const session = currentDebugSession();
|
||
return {
|
||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||
...(session ? { [DEBUG_SESSION_HEADER]: session } : {}),
|
||
};
|
||
}
|
||
|
||
// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
|
||
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
|
||
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
|
||
// would be lost.
|
||
export function beacon(url: string, body: Record<string, unknown>): void {
|
||
try {
|
||
void send(url, {
|
||
method: "POST",
|
||
keepalive: true,
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json", ...identityHeaders() },
|
||
body: JSON.stringify(body),
|
||
}).catch(() => {});
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
export const setActiveAccount = pinActiveAccount;
|
||
|
||
export async function req<T = unknown>(
|
||
url: string,
|
||
opts: RequestInit = {},
|
||
cfg: ReqConfig = {},
|
||
): Promise<T> {
|
||
const method = opts.method ?? "GET";
|
||
const canRetry = cfg.idempotent ?? method === "GET";
|
||
let attempt = 0;
|
||
|
||
for (;;) {
|
||
let r: Response;
|
||
try {
|
||
r = await send(
|
||
url,
|
||
{
|
||
credentials: "include",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
// Per-tab identity override (only setup calls and raw uploads pass their own headers:
|
||
// setup is pre-auth, and an upload spreads `identityHeaders()` into its own).
|
||
...identityHeaders(),
|
||
},
|
||
...opts,
|
||
},
|
||
attempt ? { attempt: attempt + 1 } : {},
|
||
);
|
||
} catch (e) {
|
||
// Network-level failure (incl. a keepalive connection reset surfacing as a TypeError).
|
||
if (canRetry && attempt === 0) {
|
||
attempt++;
|
||
await delay(250);
|
||
continue;
|
||
}
|
||
markConnectivityLost(); // the connection notice; the failed action is still the caller's
|
||
throw e;
|
||
}
|
||
|
||
// A gateway error usually means the upstream connection was reset before our request
|
||
// was processed — retry idempotent calls once on a fresh connection before surfacing it.
|
||
if (!r.ok && canRetry && attempt === 0 && RETRIABLE_GATEWAY.has(r.status)) {
|
||
attempt++;
|
||
await delay(250);
|
||
continue;
|
||
}
|
||
|
||
// The server answered (anything that isn't a gateway error means it's reachable) —
|
||
// clear a standing "connection lost" status if one is showing.
|
||
if (!RETRIABLE_GATEWAY.has(r.status)) markConnectivityRestored();
|
||
|
||
if (!r.ok) {
|
||
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
|
||
let detail: string | undefined;
|
||
let detailData: unknown;
|
||
try {
|
||
const body = await r.json();
|
||
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
|
||
if (body && body.detail && typeof body.detail === "object") {
|
||
detailData = body.detail;
|
||
detail = localizeDetail(body.detail); // structured error → localized message
|
||
}
|
||
} catch {
|
||
/* no JSON body */
|
||
}
|
||
const err = new HttpError(r.status, detail, detailData);
|
||
reportFailure(err, !!cfg.quiet);
|
||
throw err;
|
||
}
|
||
// A file (`cfg.raw`): the answer itself, for the caller to read as a blob. For a SMALL generated
|
||
// file whose endpoint can refuse (a report, an export): sent as a plain `<a href>` it would
|
||
// carry no refusal the app could show — the browser would simply save the error page. A large
|
||
// stored file stays a plain navigation (the download centre, the Plex original), so the browser
|
||
// streams it to disk instead of buffering gigabytes into the tab.
|
||
if (cfg.raw) return r as T;
|
||
// The single audited cast in this layer: the server contract is asserted here, once, so
|
||
// every typed api.* method (T comes from its declared return type) is checked from here up.
|
||
return (r.status === 204 ? null : await r.json()) as T;
|
||
}
|
||
}
|
||
|
||
// Upload with progress. `req` rides `fetch`, which can't report upload progress, so a file upload
|
||
// uses XHR instead: the caller gets a 0–100% callback as the body streams and an optional
|
||
// AbortSignal to cancel a large upload. Everything else mirrors `req` — the per-tab account header,
|
||
// credentials, the structured-detail localization, the global error modal for 4xx/5xx, and an
|
||
// `HttpError` throw — so callers handle failures exactly as they do a `req` call.
|
||
//
|
||
// `body` is a `Blob`/`File` — the bytes themselves, and nothing else. `FormData` was the other
|
||
// accepted shape until P1 and is deliberately gone: a multipart part is a DECLARED body, which
|
||
// FastAPI spools to disk before the route's auth dependency runs, and the backend dropped
|
||
// `python-multipart` altogether, so there is no longer anything on the other end that could parse
|
||
// one. `cfg.headers` is how a caller sends the metadata that used to ride as sibling form fields.
|
||
//
|
||
// Content-Type is never set here: the browser derives it from the Blob's own type, and a wrong one
|
||
// is worse than none.
|
||
export function uploadReq<T = unknown>(
|
||
url: string,
|
||
body: Blob,
|
||
cfg: {
|
||
onProgress?: (pct: number) => void;
|
||
signal?: AbortSignal;
|
||
headers?: Record<string, string>;
|
||
} = {},
|
||
): Promise<T> {
|
||
return new Promise<T>((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open("POST", url);
|
||
xhr.withCredentials = true;
|
||
const requestId = newRequestId();
|
||
const started = performance.now();
|
||
xhr.setRequestHeader(REQUEST_ID_HEADER, requestId);
|
||
// `identityHeaders()` LAST, and LOWER-CASED on the way in: who the request is from is this
|
||
// layer's to state, not the caller's. Merged the other way round a caller could silently
|
||
// re-attribute the upload; merged case-sensitively, a caller spelling the same header in
|
||
// another case would not be replaced but APPENDED — `setRequestHeader` combines values for a
|
||
// name that already exists, so the wire would carry both, comma-joined. Header names are
|
||
// case-insensitive, so the merge has to be too.
|
||
const merged = new Map<string, [string, string]>(); // lower-cased name -> [as spelled, value]
|
||
for (const [name, value] of Object.entries(cfg.headers ?? {}))
|
||
merged.set(name.toLowerCase(), [name, value]);
|
||
for (const [name, value] of Object.entries(identityHeaders()))
|
||
merged.set(name.toLowerCase(), [name, value]);
|
||
for (const [name, value] of merged.values()) xhr.setRequestHeader(name, value);
|
||
if (cfg.onProgress) {
|
||
xhr.upload.onprogress = (e) => {
|
||
if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100));
|
||
};
|
||
}
|
||
if (cfg.signal) {
|
||
if (cfg.signal.aborted) return reject(new HttpError(0, ABORTED));
|
||
cfg.signal.addEventListener("abort", () => xhr.abort());
|
||
}
|
||
xhr.onload = () => {
|
||
logRequest(requestId, "POST", url, started, {
|
||
status: xhr.status,
|
||
// Test doubles may not implement it; a real XHR always does.
|
||
route: xhr.getResponseHeader?.(ROUTE_HEADER),
|
||
quiet: xhr.getResponseHeader?.(QUIET_HEADER) === "1",
|
||
});
|
||
if (!RETRIABLE_GATEWAY.has(xhr.status)) markConnectivityRestored(); // as in `req`
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
try {
|
||
resolve((xhr.responseText ? JSON.parse(xhr.responseText) : null) as T);
|
||
} catch {
|
||
resolve(null as T);
|
||
}
|
||
return;
|
||
}
|
||
let detail: string | undefined;
|
||
let detailData: unknown;
|
||
try {
|
||
const body = JSON.parse(xhr.responseText);
|
||
// A string detail is not the app's (see `HttpError.detail`) and is not shown.
|
||
if (body && body.detail && typeof body.detail === "object") {
|
||
detailData = body.detail;
|
||
detail = localizeDetail(body.detail);
|
||
}
|
||
} catch {
|
||
/* no JSON body */
|
||
}
|
||
const err = new HttpError(xhr.status, detail, detailData);
|
||
reportFailure(err, false);
|
||
reject(err);
|
||
};
|
||
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
|
||
xhr.onerror = () => {
|
||
logRequest(requestId, "POST", url, started, { failed: "network" });
|
||
markConnectivityLost();
|
||
reject(new HttpError(0, i18n.t("errors.offline.body")));
|
||
};
|
||
xhr.onabort = () => {
|
||
logRequest(requestId, "POST", url, started, { failed: "aborted" });
|
||
reject(new HttpError(0, ABORTED));
|
||
};
|
||
xhr.send(body);
|
||
});
|
||
}
|