import type { Contexts, EvaluationPayload, CollectContextMode } from "./types"; export type LoaderParams = { sdkKey: string; contexts: Contexts; /** Ordered list of API base URLs to try for failover. */ apiUrls?: string[]; /** * Active domain used to derive default apiUrls when `apiUrls` is omitted. * See `InitOptions.domain` for resolution order. */ domain?: string; timeout?: number; /** * How long the hedge waits for the primary (apiUrls[0]) before ALSO firing * the secondary leg(s) in parallel. See {@link DEFAULT_HEDGE_DELAY}. */ hedgeDelay?: number; collectContextMode?: CollectContextMode; clientVersion?: string; }; /** * Result of a {@link Loader.load}. * * `payload` ALWAYS holds the evaluations for the context that was just * requested — a fresh body on a 200, or the body cached from that context's * previous 200 on a 304. This is load-bearing: the SDK keeps a single * `_configs` slot shared across contexts, and `updateContext()` switches the * context between polls. If a 304 returned no payload (as an earlier version * did), the caller would keep whatever context's data happened to be in * `_configs` — serving the WRONG context's values. Returning the matching * cached payload keeps `updateContext()`'s contract honest. * * `notModified` is the optimization hint: `true` means the server confirmed * this exact (context, version) is unchanged, so a caller whose cache already * reflects this context can skip re-applying it. */ export type LoaderResult = { notModified: boolean; payload: EvaluationPayload; /** * True when this payload came from the last-known-good localStorage cache * because every API URL failed (spec 5h), rather than from the network. The * caller marks the served config stale (reason STALE) so consumers know it is * non-authoritative until the network recovers. */ stale?: boolean; }; export default class Loader { sdkKey: string; contexts: Contexts; apiUrls: string[]; timeout: number; hedgeDelay: number; collectContextMode: CollectContextMode; clientVersion: string; /** * Every fetch leg currently in flight. The hedge runs the primary and * secondary legs concurrently, so a single shared controller (as the old * sequential loop used) would clobber one leg's abort with the other's. We * track them as a set and abort the whole set when a new load() supersedes an * in-flight one (e.g. an updateContext racing a poll tick). */ private inFlight; /** * Per-URL cache of {etag, payload} from prior 200 responses, keyed by the * FULL request URL (which embeds the encoded context). Keying per-URL — * rather than a single shared field like sdk-node — is the safety invariant: * an ETag is only ever sent back to the exact URL that minted it, so a context * switch (a different URL) can never replay a stale ETag and get a wrong 304. * The server's ETag also folds in both the workspace version and the context * token, so a stale entry can at worst yield a fresh 200, never stale data. * * We cache the full payload alongside the ETag so a 304 can return the * matching context's evaluations (see {@link LoaderResult}). Bounded as an LRU * so it can't grow without limit as contexts change, while still letting a * small set of alternating contexts (e.g. a segment MATCH/MISS probe, or * multi-tenant switching) each keep their 304 fast-path. */ private cache; private static readonly CACHE_LIMIT; constructor({ sdkKey, contexts, apiUrls, domain, timeout, hedgeDelay, collectContextMode, clientVersion, }: LoaderParams); url(apiUrl: string): string; /** * Load config, returning the FIRST leg to succeed. Thin wrapper over * {@link loadHedged} preserving the single-result contract used by callers * that only need one payload (and by the loader unit tests). The heal-forward * drain (a late, newer primary leg arriving after the secondary painted) is * only surfaced through `loadHedged`'s onResult callback, so the polling path * in `Quonfig` uses that directly. */ load(): Promise; /** * Hedged load (spec 5e). Fires the primary (apiUrls[0]) immediately and, only * if the primary is slow (no answer within {@link hedgeDelay}) or errors * fast, fires the secondary leg(s) (apiUrls[1+]) IN PARALLEL — it does not * cancel the primary. `onResult` is invoked for EVERY leg that returns a usable * result, in arrival order, so the caller can drain them all through its * reject-older install guard (highest generation wins, not first-arrival): a * stale secondary painting first never stops a later, newer primary from * healing forward (spec 5f.1). * * The returned promise resolves as soon as the FIRST leg succeeds (so first * paint / init is not blocked on a slow primary) while the remaining legs keep * running in the background and continue to feed `onResult`. It rejects only * if EVERY leg fails. A fast primary success suppresses the secondary entirely * (zero extra requests in the common case). */ loadHedged(onResult: (result: LoaderResult) => void): Promise; /** Abort and forget every in-flight fetch leg. */ private abortInFlight; /** * The last-known-good cache entry for the current (sdkKey, context), or * undefined if there is none / localStorage is unavailable. Keyed * host-agnostically so an entry persisted while talking to the primary is * still served when both primary and secondary are unreachable. */ private readLastKnownGood; /** * Cache the {etag, payload} for a URL, evicting the least-recently-stored * entry once the LRU cap is exceeded. Re-storing a key moves it to the * most-recent end. */ private remember; private fetchFromUrl; }