/** * Ingest transport — one plain fetch to `POST /cfg/monitor/ingest/`. * * The endpoint is AllowAny + rate-limited on the backend and always answers * 202; nothing here needs auth headers or a generated client. * * ───────────────────────────────────────────────────────────────────────────── * WHY THIS DOES NOT USE @djangocfg/analytics' BeaconTransport * ───────────────────────────────────────────────────────────────────────────── * It would be the obvious dedup, and it is wrong: BeaconTransport is * fire-and-forget, while `sendBatch` MUST reject on failure — `store.ts` drives * a circuit breaker off that rejection (BREAKER_THRESHOLD / BREAKER_COOLDOWN_MS), * and `server.ts` awaits it outside the browser entirely. Swapping in a * fire-and-forget transport would silently disable the breaker, so a backend * outage would turn into an unbounded retry loop from every open tab. * * What IS shared is the rule below, and it is the part that was actually broken. * * ───────────────────────────────────────────────────────────────────────────── * THE UNLOAD PATH MUST NOT SEND application/json * ───────────────────────────────────────────────────────────────────────────── * `application/json` is NOT CORS-safelisted, so a cross-origin request carrying * it leaves no-cors mode and triggers an OPTIONS preflight. During page unload * that preflight usually cannot complete, so the POST is never sent — and the * events lost are the ones that mattered most: whatever crashed the page and * caused the navigation away. * * `text/plain` IS CORS-safelisted, so the same JSON body is delivered with no * preflight. The body is unchanged; only the label differs. The server reads it * with an additive parser scoped to the ingest view. * * Same trap, same fix, as @djangocfg/analytics. Verified in Chrome 149. */ import { INGEST_PATH } from './internal' import type { DevtoolsEvent } from './types' export async function sendBatch( baseUrl: string, events: DevtoolsEvent[], useBeacon = false, ): Promise { if (events.length === 0) return // No API origin configured → drop the batch instead of posting to a relative // path. A relative URL resolves against the CURRENT page, so on an // i18n-prefixed route this would POST to `//cfg/monitor/ingest/` on // the site's own origin: a guaranteed 404, and — because a failed send trips // the circuit breaker in `store.ts` — a retry loop from every open tab. // Returning cleanly keeps the breaker closed and the app silent. if (!baseUrl) return const res = await fetch(`${baseUrl}${INGEST_PATH}`, { method: 'POST', headers: { // Unload flushes must stay CORS-simple or they never leave the browser. 'Content-Type': useBeacon ? 'text/plain' : 'application/json', }, body: JSON.stringify({ events }), credentials: 'include', ...(useBeacon ? { keepalive: true } : {}), }) if (!res.ok) throw new Error(`ingest ${res.status}`) }