pending-task-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react.ts","../src/safe-storage.ts","../src/poll-lease.ts","../src/store.ts","../src/result-relay.ts","../src/tab-lock.ts","../src/engine.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\"\nimport { PendingTaskPoller, type PendingTaskPollerOptions } from \"./engine\"\n\nexport type { PendingTaskPollerOptions } from \"./engine\"\nexport { PendingTaskPoller } from \"./engine\"\n\n/**\n * Mounts a `PendingTaskPoller` for the lifetime of the component. Re-creates the poller\n * whenever `enabled` or the registry/store identity changes (pass a stable `registry` and\n * `store` — module-level singletons, not literals re-created per render).\n *\n * The engine has no notion of auth/sessions — if the poller should only run while the user\n * is authenticated, compute `enabled` from your own auth state (e.g. `enabled: !!token`) and\n * pass it in; the hook tears the poller down whenever `enabled` goes false.\n *\n * Also forces an immediate re-check when the tab regains visibility, so a task doesn't\n * sit stale for a full `pollTickMs` after the user tabs back in.\n *\n * This hook renders nothing and returns nothing — it's a side-effect-only driver, meant\n * to be mounted once near the app root.\n */\nexport function usePendingTaskPoller<TType extends string = string>(\n options: PendingTaskPollerOptions<TType> & { enabled?: boolean },\n): void {\n const { enabled = true, ...pollerOptions } = options\n const optionsRef = useRef(pollerOptions)\n optionsRef.current = pollerOptions\n\n useEffect(() => {\n if (!enabled) return\n\n // Every callback reads through `optionsRef` at call time, not at poller-construction\n // time, so a new `onResult`/`onCheckError`/etc. closure from a re-render takes effect\n // immediately without needing to tear down and rebuild the poller (only `store`/`registry`\n // identity and `enabled` do that, since those genuinely need a fresh instance). This must\n // cover every callback option `PendingTaskPollerOptions` adds, not just the ones that\n // existed when this hook was first written — a callback left out of this list (only\n // reachable via the initial `...optionsRef.current` spread) would silently pin itself to\n // whatever closure was captured at mount, which defeats the whole point for anything that\n // reads live app state (e.g. `acceptRelayedResult` checking the currently signed-in user).\n const poller = new PendingTaskPoller({\n ...optionsRef.current,\n onResult: (detail) => optionsRef.current.onResult?.(detail),\n onCheckError: (error, task) => optionsRef.current.onCheckError?.(error, task),\n claimResultOnce: (task) =>\n optionsRef.current.claimResultOnce ? optionsRef.current.claimResultOnce(task) : true,\n acceptRelayedResult: (detail) => optionsRef.current.acceptRelayedResult?.(detail) ?? true,\n })\n poller.start()\n\n const handleVisibility = () => {\n if (document.visibilityState === \"visible\") {\n poller.forceCheckAll()\n }\n }\n document.addEventListener(\"visibilitychange\", handleVisibility)\n\n return () => {\n document.removeEventListener(\"visibilitychange\", handleVisibility)\n poller.stop()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [enabled, pollerOptions.store, pollerOptions.registry])\n}\n","/**\n * Thin wrappers around `localStorage` access that degrade safely instead of throwing: reads\n * return a safe fallback, and writes/removals never throw, whether `localStorage` is\n * unavailable entirely (SSR, no `window`) or the call itself throws (quota exceeded,\n * Safari private browsing, storage disabled). `safeSetItem` reports whether the write actually\n * landed, for a caller that wants to know rather than assume — none of this package's own\n * callers currently branch on it (e.g. `poll-lease.ts`'s lease claim is deliberately fail-open\n * regardless, on the reasoning in `writeLease`'s own doc comment), but the honest signal is\n * cheap to provide now and would be easy to forget to add back later if some future caller\n * needs it.\n *\n * Shared by every localStorage-backed primitive in this package (`poll-lease`, `result-relay`,\n * `ttl-dedupe-cache`) so this failure handling lives in one place instead of being\n * re-implemented per module.\n */\n\nexport function safeGetItem(key: string): string | null {\n try {\n // The `typeof` check itself, not just the `.getItem` call after it, needs to be inside this\n // try: browsers that block storage access (cookies/site data disabled) can make the\n // `localStorage` global itself a throwing accessor, so even evaluating `typeof localStorage`\n // — which merely reads it to determine its type — can throw a SecurityError before the\n // `=== \"undefined\"` check ever gets to run.\n if (typeof localStorage === \"undefined\") return null\n return localStorage.getItem(key)\n } catch {\n return null\n }\n}\n\n/** Returns true if the write actually landed, false if it silently failed (or there's no\n * `localStorage` to write to). */\nexport function safeSetItem(key: string, value: string): boolean {\n try {\n // See safeGetItem's comment on why the `typeof` check itself must be inside this try too.\n if (typeof localStorage === \"undefined\") return false\n localStorage.setItem(key, value)\n return true\n } catch {\n return false\n }\n}\n\nexport function safeRemoveItem(key: string): void {\n try {\n // See safeGetItem's comment on why the `typeof` check itself must be inside this try too.\n if (typeof localStorage === \"undefined\") return\n localStorage.removeItem(key)\n } catch {\n // Removal failing (storage disabled) leaves the stale entry in place until it's next\n // overwritten or expires on its own — the same degrade-safely tradeoff every caller here makes.\n }\n}\n","import { safeGetItem, safeSetItem } from \"./safe-storage\"\n\ninterface PollLeaseRecord {\n ownerId: string\n /** Monotonically increasing generation number for this lease. Bumped on every claim that\n * isn't a plain renewal of the same still-valid tenure (i.e. whenever the previous holder was\n * someone else, or nobody, or this same owner's own previous claim had already expired) — see\n * `PollLeaseClaimResult` for why callers need this, not just the owner id, to detect a lost\n * and regained lease. */\n fence: number\n expiresAt: number\n}\n\n/**\n * The result of a `claim()` call. On success, carries the lease's current `fence` — capture it\n * alongside the fact that you're leader, and pass it back into a later `claim()` call (see\n * `PendingTaskPoller.reconfirmLeadership`) to detect not just \"is nobody else holding this lease\n * right now\" but \"has it been mine, continuously, since I captured this fence.\" A plain\n * owner-id check can't tell those apart: if this tab's lease expires mid-operation, another tab\n * claims it, fully finishes with it, and that tab's own lease *also* later expires before this\n * tab checks back in, this tab can legitimately reclaim the (by then unheld) lease under its own\n * stable owner id — succeeding a same-owner-id check despite leadership having genuinely\n * churned through someone else in between. The fence will have moved on regardless, so comparing\n * it (not just the owner id) catches this.\n */\nexport type PollLeaseClaimResult = { leader: true; fence: number } | { leader: false }\n\nexport interface PollLeaseClaimer {\n /**\n * Returns `{ leader: true, fence }` if `ownerId` is the poll leader from now until this\n * lease's TTL — either it just claimed an unheld/expired lease (a new `fence`), or it's\n * renewing the tenure it already holds (the same `fence` as last time). Returns\n * `{ leader: false }` if a different, still-unexpired owner holds the lease. Still reports\n * success even if persisting this tab's own claim silently failed — see `writeLease`'s doc\n * comment for why that's the safer failure mode than reporting the claim as lost.\n *\n * Not itself cross-tab-atomic — the caller (see `PendingTaskPoller`) is expected to run this\n * inside a `withTabLock` critical section the way `PendingTaskPoller.claimLeadership` does.\n * (`createTtlDedupeCache` and the store's own mutators are plain, unlocked \"read → decide →\n * write\" primitives themselves — README-recommended `claimResultOnce` compositions wrap\n * `createTtlDedupeCache.claim` in their own `withTabLock` call for the same reason this\n * module's caller does, but neither primitive locks internally on its own.)\n */\n claim(ownerId: string): PollLeaseClaimResult\n /**\n * Voluntarily gives up the lease — but only if `ownerId` is the one currently holding it,\n * never another owner's. Call this on a graceful shutdown (e.g. `PendingTaskPoller.stop()`)\n * so another open tab doesn't have to wait out the full TTL before taking over; an abrupt\n * closure (crash, killed tab, or a tab frozen in the browser's back/forward cache) is still\n * handled safely by the lease simply expiring on its own — that's *why* this is a renewable\n * TTL claim rather than a held Web Lock: a frozen tab's timers stop, so it stops renewing,\n * and any other open tab picks up leadership on its next tick without needing an explicit\n * release that a frozen or killed tab could never send.\n */\n release(ownerId: string): void\n}\n\nfunction readLease(storageKey: string): PollLeaseRecord | null {\n const raw = safeGetItem(storageKey)\n if (!raw) return null\n try {\n const parsed = JSON.parse(raw) as Partial<PollLeaseRecord>\n if (\n typeof parsed.ownerId !== \"string\" ||\n typeof parsed.expiresAt !== \"number\" ||\n typeof parsed.fence !== \"number\"\n ) {\n return null\n }\n return parsed as PollLeaseRecord\n } catch {\n return null\n }\n}\n\nfunction writeLease(storageKey: string, lease: PollLeaseRecord): void {\n // A write failing (quota exceeded, private-mode Safari, storage disabled) is deliberately not\n // surfaced to the caller as a failed claim: `claim()` still reports success so this tab keeps\n // polling instead of going silent forever if storage stays broken. The narrower risk — this\n // tab's own claim never lands while a different, healthy tab's *does* land, so both act as\n // leader for one tick — is self-limiting: `readLease` (unlike writes) keeps working under a\n // plain quota failure, so on this tab's very next `claim()` call it reads that other tab's now\n // real lease and correctly steps back. Only a storage outage severe enough to break reads too\n // (rare, and one where cross-tab coordination is impossible either way) leaves both tabs\n // polling independently — the same outcome as running with this feature off entirely.\n safeSetItem(storageKey, JSON.stringify(lease))\n}\n\n/**\n * Creates a renewable, localStorage-backed \"poll leader\" lease: at most one owner is\n * considered current at a time, but — unlike a held mutex — that fact expires on its own\n * (`ttlMs` after the last successful `claim`) rather than requiring an explicit release,\n * so a leader that stops renewing (closed, crashed, or frozen) can't permanently block\n * every other owner from taking over. See `PollLeaseClaimer.release` for why this matters.\n */\nexport function createPollLeaseClaimer(storageKey: string, ttlMs: number): PollLeaseClaimer {\n if (ttlMs <= 0 && typeof console !== \"undefined\") {\n // A non-positive TTL makes every claim expire before (or the instant) it's written, so\n // election silently stops electing anyone — every tab's every claim looks like a fresh,\n // unheld one, and the fence climbs on every single call instead of settling once a tab\n // holds an uncontested lease. Not fatal (best-effort election just degrades to \"every tab\n // polls independently,\" same as turning `crossTabPollLeaderElection` off), but almost\n // certainly a misconfiguration, so it's worth flagging at the point it's easiest to notice.\n console.warn(`pending-task-kit: pollLeaseTtlMs must be positive, got ${ttlMs}`)\n }\n return {\n claim(ownerId) {\n const current = readLease(storageKey)\n const now = Date.now()\n if (current && current.ownerId !== ownerId && current.expiresAt > now) {\n return { leader: false }\n }\n // A renewal (same owner, still within its own still-valid tenure) keeps the current\n // fence; anything else claiming successfully — nobody held it, it was someone else's, or\n // it was this same owner's but had already expired — starts a new one, since a gap wide\n // enough for another tab to have claimed, used, and released the lease in between can't be\n // ruled out from this read alone.\n const isRenewal = current !== null && current.ownerId === ownerId && current.expiresAt > now\n const fence = isRenewal ? current.fence : (current?.fence ?? 0) + 1\n writeLease(storageKey, { ownerId, fence, expiresAt: now + ttlMs })\n return { leader: true, fence }\n },\n release(ownerId) {\n const current = readLease(storageKey)\n if (current?.ownerId === ownerId) {\n // Written back already-expired rather than removed outright: removing the record would\n // forget `fence`, so the next claim (by this owner or another) would restart it from 1\n // and could collide with a fence value some in-flight `reconfirmLeadership` call is still\n // holding onto from before this release — exactly the ambiguity fencing exists to\n // prevent. Writing an expired record instead lets any tab claim immediately (same\n // end result as removal) while keeping the generation counter strictly increasing.\n writeLease(storageKey, { ownerId, fence: current.fence, expiresAt: 0 })\n }\n },\n }\n}\n\n/** A per-instance random id stable for as long as the caller holds onto it — e.g. one\n * `PendingTaskPoller` instance's lifetime. Falls back to a non-cryptographic id when\n * `crypto.randomUUID` isn't available (older browsers, non-secure contexts); this only\n * needs to be unlikely to collide with another tab's id, not cryptographically unguessable. */\nexport function generatePollOwnerId(): string {\n if (typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\") {\n return crypto.randomUUID()\n }\n return `${Date.now()}-${Math.random().toString(36).slice(2)}`\n}\n","import { create, type StoreApi, type UseBoundStore } from \"zustand\"\nimport { createJSONStorage, persist } from \"zustand/middleware\"\nimport type { PendingTask } from \"./types\"\n\nexport const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000\nexport const DEFAULT_STORAGE_KEY = \"pending-tasks\"\n\nexport interface PendingTaskStoreState<TType extends string = string> {\n tasks: PendingTask<TType>[]\n addTask: (task: PendingTask<TType>) => void\n removeTask: (id: string) => void\n updateTask: (id: string, patch: Partial<PendingTask<TType>>) => void\n /** Removes every task for which `predicate` returns false — e.g. drop tasks that don't\n * belong to the account now signed in, however your app identifies \"belongs to\". */\n pruneTasksBy: (predicate: (task: PendingTask<TType>) => boolean) => void\n clearAllTasks: () => void\n}\n\nexport type PendingTaskStore<TType extends string = string> = UseBoundStore<\n StoreApi<PendingTaskStoreState<TType>>\n> & {\n storageKey: string\n /**\n * True when the most recent write to this store's localStorage entry threw (quota exceeded,\n * Safari private browsing, storage disabled, ...) instead of landing — by whichever writer\n * made it: this store's own mutators, or an external batched write via `writeTasks` (e.g.\n * `PendingTaskPoller`'s `flushBatch`/cross-tab `storage` sync). While true, persisted storage\n * no longer reflects this tab's in-memory state, so anything about to rebuild `tasks` from a\n * freshly-read persisted snapshot should build on `getState().tasks` instead, and anything\n * checking \"does another tab already have this task\" (e.g. `addTaskIfMissing`) should not\n * trust a persisted-storage read either. Flips back to `false` as soon as a write through\n * this store's own mutators or `writeTasks` succeeds again.\n *\n * This is the single shared source of truth for that fact — treat it as read-only from\n * outside this module; it's written only by this store's own mutators and by `writeTasks`.\n */\n hasUnpersistedWrites: boolean\n /**\n * Writes `tasks` to this store the same way its own mutators do, through the single shared\n * safe-write path that also updates `hasUnpersistedWrites`. Anything outside this module that\n * replaces the whole `tasks` array wholesale (currently `PendingTaskPoller`'s batched\n * `flushBatch` writes and its cross-tab `storage`-event sync) must go through this instead of\n * calling `setState` directly — otherwise its own write failures would be invisible to this\n * store's mutators (and vice versa), letting the two silently drift out of sync about whether\n * persisted storage can currently be trusted.\n */\n writeTasks: (tasks: PendingTask<TType>[]) => void\n}\n\nexport interface CreatePendingTaskStoreOptions {\n /** localStorage key. Defaults to `\"pending-tasks\"`. Must be unique per app if you run multiple stores. */\n storageKey?: string\n}\n\nexport function isPendingTaskShape(value: unknown): value is PendingTask {\n if (!value || typeof value !== \"object\") return false\n const t = value as Record<string, unknown>\n return (\n typeof t.id === \"string\" &&\n typeof t.type === \"string\" &&\n typeof t.startedAt === \"number\" &&\n (typeof t.taskId === \"number\" || typeof t.taskId === \"string\")\n )\n}\n\n/** Parses the raw string a zustand-persist localStorage entry holds, tolerating garbage/foreign values. */\nexport function parseTasksFromStorageValue<TType extends string = string>(\n value: string | null,\n): PendingTask<TType>[] {\n if (!value) return []\n try {\n const parsed = JSON.parse(value) as unknown\n const tasks = (parsed as { state?: { tasks?: unknown } } | null)?.state?.tasks\n if (!Array.isArray(tasks)) return []\n return tasks.filter(isPendingTaskShape) as PendingTask<TType>[]\n } catch {\n return []\n }\n}\n\n/** Reads a store's persisted tasks directly from localStorage, bypassing its in-memory\n * snapshot — useful right before a cross-tab existence check, since the in-memory state\n * in this tab may not yet reflect a write another tab just made. */\nexport function readPersistedTasks<TType extends string = string>(\n storageKey: string,\n): PendingTask<TType>[] {\n if (typeof localStorage === \"undefined\") return []\n return parseTasksFromStorageValue<TType>(localStorage.getItem(storageKey))\n}\n\n/**\n * Creates an isolated pending-task store. Each store persists to its own localStorage key,\n * so most apps should create exactly one instance and share it (module-level singleton).\n *\n * Every mutator re-reads the persisted value before writing, rather than trusting the\n * in-memory snapshot — this avoids resurrecting a task another tab already removed.\n */\nexport function createPendingTaskStore<TType extends string = string>(\n options: CreatePendingTaskStoreOptions = {},\n): PendingTaskStore<TType> {\n const storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY\n const readPersisted = (): PendingTask<TType>[] => readPersistedTasks<TType>(storageKey)\n\n // zustand's persist writes to localStorage synchronously inside setState() — a throwing\n // write (quota exceeded, Safari private browsing, storage disabled) would otherwise propagate\n // straight out of whichever caller triggered it. The in-memory state has already been applied\n // by the time persist's write runs, so swallowing the write failure here just degrades\n // persistence to this-tab-only rather than crashing the caller (same treatment\n // `createTtlDedupeCache` already gives this failure mode). This is the ONLY place that writes\n // `useStore.hasUnpersistedWrites` — this store's own mutators and `writeTasks` (the external\n // entry point `PendingTaskPoller` uses for its batched/cross-tab writes) both route through\n // it, so a write failure on either path is visible to both instead of each tracking its own\n // disconnected flag.\n const writeTasks = (tasks: PendingTask<TType>[]): void => {\n try {\n useStore.setState({ tasks })\n useStore.hasUnpersistedWrites = false\n } catch {\n useStore.hasUnpersistedWrites = true\n }\n }\n\n const useStore = create<PendingTaskStoreState<TType>>()(\n persist(\n (_set, get) => {\n // `hasUnpersistedWrites` remembers a write failure: once one happens, localStorage no\n // longer reflects this tab's state, so the next mutator must build on the in-memory\n // snapshot (`get().tasks`) instead of `readPersisted()` — otherwise it would silently\n // resurrect the stale persisted snapshot and discard whatever only lives in memory.\n // Once a write succeeds again, persisted and memory are back in sync, so mutators go\n // back to reading persisted first (to avoid resurrecting a task another tab removed).\n const base = (): PendingTask<TType>[] =>\n useStore.hasUnpersistedWrites ? get().tasks : readPersisted()\n\n return {\n tasks: [],\n addTask: (task) => {\n const next = base().filter((t) => t.id !== task.id)\n next.push(task)\n writeTasks(next)\n },\n removeTask: (id) => {\n writeTasks(base().filter((t) => t.id !== id))\n },\n updateTask: (id, patch) => {\n writeTasks(base().map((t) => (t.id === id ? { ...t, ...patch } : t)))\n },\n pruneTasksBy: (predicate) => {\n writeTasks(base().filter(predicate))\n },\n clearAllTasks: () => writeTasks([]),\n }\n },\n {\n name: storageKey,\n storage: createJSONStorage(() => localStorage),\n partialize: (state) => ({ tasks: state.tasks }),\n },\n ),\n ) as unknown as PendingTaskStore<TType>\n\n useStore.storageKey = storageKey\n useStore.hasUnpersistedWrites = false\n useStore.writeTasks = writeTasks\n return useStore\n}\n","import { safeRemoveItem, safeSetItem } from \"./safe-storage\"\nimport { isPendingTaskShape } from \"./store\"\nimport type { PendingTaskResultEventDetail, PendingTaskResultStatus } from \"./types\"\n\n// \"expired\" is included for completeness against the full `PendingTaskResultStatus` union, but\n// the engine never actually relays it in practice — `finalize()` returns before ever reaching\n// the relay write for an \"expired\" outcome (expiry is silent by design, never dispatched to\n// `onResult`/DOM listeners either). Accepting it here just means this parser doesn't need to\n// track that engine-side detail to stay correct — it validates against the type's own shape.\nconst RESULT_STATUSES: readonly PendingTaskResultStatus[] = [\n \"success\",\n \"failure\",\n \"error\",\n \"expired\",\n]\n\nfunction isResultStatus(value: unknown): value is PendingTaskResultStatus {\n return typeof value === \"string\" && (RESULT_STATUSES as readonly string[]).includes(value)\n}\n\n/**\n * Only meaningful when cross-tab poll-leader election is on (see `PendingTaskPoller`'s\n * `crossTabPollLeaderElection` option): in that mode, only the elected leader tab ever calls\n * `handler.check()` and detects a task's outcome, so only it would ever locally\n * `dispatchEvent(...)` the result — every other open tab would otherwise never see it. This\n * writes the outcome to a dedicated localStorage key so every other tab's native `storage`\n * listener fires and can re-dispatch the same event on its own `window`, keeping any\n * page-level UI that listens for it working the same as if leader election were off.\n *\n * Holds only the single most recent result, not a queue — a second write before another tab's\n * listener runs still fires its own separate `storage` event (browsers dispatch one per\n * `setItem` call, not a coalesced \"latest value only\" notification), so no result is dropped\n * by being overwritten before it's read.\n */\nexport function writeResultRelay<TType extends string = string>(\n storageKey: string,\n detail: PendingTaskResultEventDetail<TType>,\n): void {\n try {\n // `data` is a free-form, handler-supplied payload (type `unknown`) — JSON.stringify itself\n // (not just the localStorage write safeSetItem already guards) can throw on a circular\n // reference or a BigInt in there. Letting that escape would abort finalize() mid-dispatch,\n // taking the rest of this tick's tasks down with it — degrade the same way every other\n // localStorage-adjacent failure in this package does instead: silently skip this one relay.\n const serialized = JSON.stringify(detail)\n // Write failing means only this leader tab's own (already-dispatched) window sees the\n // result this time — other tabs miss this one relay, same degrade-safely tradeoff as\n // every other localStorage write in this package.\n safeSetItem(storageKey, serialized)\n } catch {\n // Non-serializable `data` — see the comment above.\n }\n}\n\n/** Parses a `storage` event's `newValue` for the relay key above, tolerating garbage/foreign\n * values the same way `parseTasksFromStorageValue` does for the task list itself. */\nexport function parseResultRelay<TType extends string = string>(\n value: string | null,\n): PendingTaskResultEventDetail<TType> | null {\n if (!value) return null\n try {\n const parsed = JSON.parse(value) as Partial<PendingTaskResultEventDetail<TType>>\n if (!isPendingTaskShape(parsed.task) || !isResultStatus(parsed.status)) {\n return null\n }\n return parsed as PendingTaskResultEventDetail<TType>\n } catch {\n return null\n }\n}\n\n/**\n * Removes whatever result is currently sitting in the relay entry — e.g. on explicit user\n * logout, if a task's `metadata`/a handler's `data` can carry PII: the relay only ever holds\n * the single most recent result (see `writeResultRelay`), so without an explicit clear it\n * would otherwise sit there indefinitely (the next write overwrites it, but nothing proactively\n * removes it), readable by a later script or a different user on the same device.\n */\nexport function clearResultRelay(storageKey: string): void {\n safeRemoveItem(storageKey)\n}\n","/**\n * Runs `operation` under a named Web Locks API lock (`navigator.locks`), so that\n * only one browser tab executes it at a time. Falls back to running `operation`\n * un-locked when the Web Locks API isn't available (older browsers, non-browser\n * environments, or insecure contexts).\n */\nexport async function withTabLock<T>(\n name: string,\n operation: () => Promise<T> | T,\n): Promise<T> {\n const locks = typeof navigator !== \"undefined\" ? navigator.locks : undefined\n\n if (!locks) {\n return operation()\n }\n\n return locks.request(name, async () => operation())\n}\n","import {\n createPollLeaseClaimer,\n generatePollOwnerId,\n type PollLeaseClaimer,\n type PollLeaseClaimResult,\n} from \"./poll-lease\"\nimport { parseResultRelay, writeResultRelay } from \"./result-relay\"\nimport { DEFAULT_STORAGE_KEY, DEFAULT_TTL_MS, parseTasksFromStorageValue, readPersistedTasks } from \"./store\"\nimport type { PendingTaskStore } from \"./store\"\nimport { withTabLock } from \"./tab-lock\"\nimport type {\n PendingTask,\n PendingTaskCheckResult,\n PendingTaskHandler,\n PendingTaskRegistry,\n PendingTaskResultEventDetail,\n} from \"./types\"\n\nexport const DEFAULT_POLL_TICK_MS = 2_000\nexport const DEFAULT_POLL_INTERVAL_MS = 10_000\nexport const DEFAULT_MAX_FAILURE_COUNT = 5\nexport const DEFAULT_RESULT_EVENT = \"pending-task-result\"\n/** Multiplied by the effective `pollTickMs` to get the default `pollLeaseTtlMs` — see that\n * option's doc comment for why it needs headroom over a single tick. */\nexport const DEFAULT_POLL_LEASE_TTL_MULTIPLIER = 4\n\nexport interface PendingTaskPollerOptions<TType extends string = string> {\n store: PendingTaskStore<TType>\n registry: PendingTaskRegistry<TType>\n /** Called for every non-silent `success`/`failure`/`error` outcome. This is where apps show a toast, navigate, or invalidate a cache — the engine has no opinion on any of that. */\n onResult?: (detail: PendingTaskResultEventDetail<TType>) => void\n /**\n * Called whenever `handler.check` throws, before the normal failure-count/backoff/expiry\n * handling runs. The engine has no notion of auth, tokens, or sessions — if `check()` can\n * fail for a reason that shouldn't count as a normal transient error (e.g. the caller's own\n * session just ended), inspect `error` here and return `true` to skip the normal failure\n * counting and stop the *current* tick early (the task is left as-is, `lastCheckedAt` is\n * still bumped so it isn't treated as overdue again immediately). Return `false`/`undefined`\n * (or omit this option) to fall through to the standard failure-count/backoff/expiry path.\n */\n onCheckError?: (error: unknown, task: PendingTask<TType>) => boolean | void\n /**\n * Optional cross-tab \"claim once\" gate around the final `onResult`/DOM-event dispatch\n * (task removal from the store always happens regardless). Compose `withTabLock` +\n * `createTtlDedupeCache` here to prevent duplicate toasts when multiple tabs race to\n * process the same completed task. This guards `onResult`/the toast-equivalent side effect\n * specifically — it's orthogonal to `crossTabPollLeaderElection`, which is about not\n * duplicating the *polling* itself; keep both if you want both properties.\n *\n * If your own implementation layers in something time-sensitive of its own — e.g. only\n * proceeding while a session is still valid — check that condition *after* your `withTabLock`\n * call resolves, not only before it: `withTabLock` is a genuine async yield (real cross-tab\n * lock arbitration), so state can legitimately change while it's pending. A check placed only\n * before it can pass, then have the underlying condition change during the wait, and the\n * claimed/dispatched side effect would still fire against the now-stale state. (`finalize()`\n * itself calls this once and acts on the result immediately after, with no further `await` in\n * between — the same \"recheck right before acting\" discipline applies to whatever you put\n * inside this callback.)\n *\n * On the leader tab specifically, returning `false` here also suppresses the cross-tab result\n * relay (see `resultRelayKey`) for this result — not just this tab's own `onResult`/DOM event.\n * That's the right call for the dedup use case above (another tab already claimed it, so that\n * other tab is the one that will relay). If you layer in a veto unrelated to dedup (the\n * session-validity check above, evaluated on the *leader's* state), a `false` there means\n * *every* open tab loses this result, including ones whose own session is still fine — for a\n * receiving-side-only veto that only affects the tab evaluating it, use `acceptRelayedResult`\n * instead (or alongside this).\n */\n claimResultOnce?: (task: PendingTask<TType>) => Promise<boolean> | boolean\n /** How often the engine re-scans the task list. Individual tasks still respect their own poll interval. */\n pollTickMs?: number\n /** Fallback per-check interval (ms) for handlers that don't set `pollIntervalMs`. */\n defaultPollIntervalMs?: number\n /** Fallback TTL (ms) for tasks whose handler doesn't set `ttlMs`. */\n defaultTtlMs?: number\n maxFailureCount?: number\n /** Also `window.dispatchEvent(new CustomEvent(eventName, { detail }))` for cross-component listening. Defaults to true when `window` exists. */\n dispatchDomEvent?: boolean\n eventName?: string\n /** localStorage key the store persists to — must match what `createPendingTaskStore` was given. */\n storageKey?: string\n /**\n * When multiple browser tabs share the same store (the normal case — the store already\n * syncs across tabs via `storage` events), only one of them actually calls `handler.check()`\n * for a given task at a time; the others skip their own network work entirely for tasks\n * that tab isn't the elected leader for, and instead learn the outcome via the (also\n * newly-enabled) result relay once the leader dispatches it — see `resultRelayKey`.\n *\n * Defaults to `true`. Safe to leave on for single-tab usage: an uncontested instance always\n * successfully claims/renews its own lease, so this changes nothing when there's no\n * contention. Turn it off only if you specifically don't want that (e.g. you're not running\n * in an environment with shared `localStorage` across the \"tabs\" this is designed for, or\n * you're intentionally running independent pollers that must each poll everything).\n */\n crossTabPollLeaderElection?: boolean\n /** localStorage key (and Web Lock name) backing the poll-leader lease. Defaults to\n * `` `${storageKey}-poll-leader` ``. Only relevant when `crossTabPollLeaderElection` is on. */\n pollLeaseKey?: string\n /**\n * How long a claimed poll-leader lease stays valid without renewal before another tab may\n * claim it. Defaults to `pollTickMs * 4` — comfortably longer than one normal tick, so a\n * live leader always renews well before expiry, but short enough that a leader that stops\n * renewing (closed, crashed, or frozen in the browser's back/forward cache) only blocks\n * takeover for a bounded, short window rather than indefinitely.\n *\n * Note this bounds *renewal cadence*, not any single `handler.check()` call's own duration:\n * a single slow request can still outlast this TTL, which is exactly why the engine\n * re-confirms leadership again right after `check()` resolves/throws, before acting on a\n * possibly-stale outcome — see the source of `runTick` if you're curious about the mechanism.\n * Raising this value doesn't need to account for that case; it only trades off how long a\n * genuinely dead leader blocks takeover.\n */\n pollLeaseTtlMs?: number\n /** localStorage key used to relay a completed task's result to other tabs when\n * `crossTabPollLeaderElection` is on (only the leader tab detects completion, so without\n * this, every other tab's `dispatchDomEvent` listeners would never fire). Defaults to\n * `` `${storageKey}-result-relay` ``. */\n resultRelayKey?: string\n /**\n * Optional gate on the *receiving* side of the cross-tab result relay (only relevant when\n * `crossTabPollLeaderElection` is on): called right before this tab re-dispatches a result\n * that arrived via another tab's `storage` write, letting this tab veto it. Return `false`\n * to skip the dispatch entirely. Omit it (the default) to always accept, matching the\n * engine's behavior before this option existed.\n *\n * The engine has no notion of sessions — if a relayed result could belong to a session that\n * has since ended in *this* tab (a different account signed in, a logout), and re-surfacing\n * it to this tab's own listeners would be wrong (the task's `metadata`/`data` can carry\n * PII), inspect `detail` here and check whatever your app considers \"still valid\" — the same\n * way `claimResultOnce` lets you gate the leader's own outgoing dispatch. This is the\n * receiving-side half of that same concern; without it, there was previously no way to\n * intercept an inbound relayed result at all.\n *\n * Evaluated synchronously with no `await` before the dispatch it gates (unlike\n * `claimResultOnce`, there's no cross-tab claim to arbitrate on this side — only this tab\n * decides whether to act on what it received, so there's no lock-arbitration window for\n * your condition to go stale in between). If your own check is inherently async (e.g. reads\n * from IndexedDB), resolve it eagerly elsewhere and read a synchronous flag here rather than\n * awaiting inline.\n */\n acceptRelayedResult?: (detail: PendingTaskResultEventDetail<TType>) => boolean\n}\n\nfunction describeError(error: unknown): string {\n if (error instanceof Error) return error.message\n return typeof error === \"string\" ? error : \"Unknown error\"\n}\n\n/**\n * Framework-agnostic polling engine: scans the store's tasks on an interval, calls the\n * matching handler's `check()`, and resolves each task to `pending` (re-check later),\n * `success`/`failure` (dispatched via `onResult`, then removed), silently-or-not `error`\n * (removed; dispatched unless `silentOnFailure`) when `check()` itself kept failing, or\n * silently expired (removed, never dispatched — unless the handler opts into\n * `finalCheckOnExpiry` for one last check).\n *\n * When multiple tabs share a store, `crossTabPollLeaderElection` (on by default) ensures only\n * one of them actually polls at a time — see that option and `resultRelayKey` for how the\n * others still learn about results without polling themselves.\n *\n * Framework bindings (see `./react`) are thin wrappers that call `start()`/`stop()` at the\n * right lifecycle moments and expose `forceCheckAll()` for e.g. tab-focus recovery.\n *\n * Note: `stop()` prevents any *new* tick from starting, but a tick already awaiting\n * `handler.check()` when `stop()` is called will still run to completion (there is no\n * `AbortSignal` plumbed into the handler contract). Design handlers to be safe to finish\n * even if the caller has logically \"stopped\" — e.g. don't assume side effects are undone.\n */\nexport class PendingTaskPoller<TType extends string = string> {\n private readonly options: Required<\n Omit<\n PendingTaskPollerOptions<TType>,\n \"onResult\" | \"onCheckError\" | \"claimResultOnce\" | \"acceptRelayedResult\"\n >\n > &\n Pick<\n PendingTaskPollerOptions<TType>,\n \"onResult\" | \"onCheckError\" | \"claimResultOnce\" | \"acceptRelayedResult\"\n >\n\n /** Stable for this instance's whole lifetime — e.g. one `PendingTaskPoller` construction per\n * browser tab (that's how the React binding uses it). Regenerating this per claim would make\n * a tab unable to recognize its own still-valid lease as \"mine\" on the next renewal. */\n private readonly ownerId: string\n private readonly pollLease: PollLeaseClaimer\n\n private intervalId: ReturnType<typeof setInterval> | undefined\n private storageListener: ((event: StorageEvent) => void) | undefined\n private isChecking = false\n private pendingForce = false\n private stopped = false\n private latestTasksCache: { tasks: PendingTask<TType>[]; byId: Map<string, PendingTask<TType>> } | undefined\n /** Task ids that already got their one `finalCheckOnExpiry` attempt, so a repeatedly-failing\n * final check doesn't get retried every tick. Reset on process restart — worst case that\n * costs one extra check, never an infinite retry loop.\n *\n * Every id added here (only when a task is expired, right before its final `check()`) is\n * removed again before the *same* tick's iteration moves past that task — either by\n * `finalize()`'s first line, or by one of the leadership-loss/`onCheckError`-intercept\n * branches that bail out without ever reaching `finalize()`. Nothing here is meant to\n * survive past the tick that added it. */\n private readonly finalCheckAttempted = new Set<string>()\n\n constructor(options: PendingTaskPollerOptions<TType>) {\n const storageKey = options.storageKey ?? options.store.storageKey ?? DEFAULT_STORAGE_KEY\n const pollTickMs = options.pollTickMs ?? DEFAULT_POLL_TICK_MS\n\n this.options = {\n store: options.store,\n registry: options.registry,\n pollTickMs,\n defaultPollIntervalMs: options.defaultPollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,\n defaultTtlMs: options.defaultTtlMs ?? DEFAULT_TTL_MS,\n maxFailureCount: options.maxFailureCount ?? DEFAULT_MAX_FAILURE_COUNT,\n dispatchDomEvent: options.dispatchDomEvent ?? typeof window !== \"undefined\",\n eventName: options.eventName ?? DEFAULT_RESULT_EVENT,\n storageKey,\n crossTabPollLeaderElection: options.crossTabPollLeaderElection ?? true,\n pollLeaseKey: options.pollLeaseKey ?? `${storageKey}-poll-leader`,\n pollLeaseTtlMs: options.pollLeaseTtlMs ?? pollTickMs * DEFAULT_POLL_LEASE_TTL_MULTIPLIER,\n resultRelayKey: options.resultRelayKey ?? `${storageKey}-result-relay`,\n onResult: options.onResult,\n onCheckError: options.onCheckError,\n claimResultOnce: options.claimResultOnce,\n acceptRelayedResult: options.acceptRelayedResult,\n }\n this.ownerId = generatePollOwnerId()\n this.pollLease = createPollLeaseClaimer(this.options.pollLeaseKey, this.options.pollLeaseTtlMs)\n }\n\n start(): void {\n this.stopped = false\n if (this.intervalId !== undefined) return\n\n this.intervalId = setInterval(() => {\n this.runTickSafely(false)\n }, this.options.pollTickMs)\n\n if (typeof window !== \"undefined\") {\n this.storageListener = (event: StorageEvent) => {\n if (event.key === null) {\n // localStorage.clear() fires with key: null — treat it as this store being wiped too.\n this.options.store.writeTasks([])\n return\n }\n if (event.key === this.options.storageKey) {\n // Goes through the store's own `writeTasks` (not `setState` directly) so a throwing\n // re-write here (this tab's own quota/private-mode issue racing another tab's write)\n // updates the same shared `hasUnpersistedWrites` fact this store's mutators and\n // `flushBatch` read, instead of escaping this handler uncaught.\n this.options.store.writeTasks(parseTasksFromStorageValue<TType>(event.newValue))\n return\n }\n if (this.options.crossTabPollLeaderElection && event.key === this.options.resultRelayKey) {\n const detail = parseResultRelay<TType>(event.newValue)\n if (!detail) return\n let accepted: boolean\n try {\n accepted = this.options.acceptRelayedResult?.(detail) ?? true\n } catch (error) {\n // A throwing acceptRelayedResult is a consumer bug, same category as an onResult\n // throw below — surface it consistently (queued, not left to escape this raw\n // \"storage\" event listener callback directly) rather than letting its behavior\n // differ from the queueMicrotask treatment onCheckError/onResult get elsewhere.\n queueMicrotask(() => {\n throw error\n })\n return\n }\n if (accepted) void this.dispatchRelayedResult(detail)\n }\n }\n window.addEventListener(\"storage\", this.storageListener)\n }\n\n this.runTickSafely(false)\n }\n\n stop(): void {\n this.stopped = true\n this.pendingForce = false\n\n if (this.intervalId !== undefined) {\n clearInterval(this.intervalId)\n this.intervalId = undefined\n }\n if (this.storageListener && typeof window !== \"undefined\") {\n window.removeEventListener(\"storage\", this.storageListener)\n this.storageListener = undefined\n }\n if (this.options.crossTabPollLeaderElection) {\n // Best-effort and not awaited — stop() is a synchronous API. If this never lands (page\n // unloading right now, storage disabled), the lease just expires on its own TTL instead\n // of being released early; see PollLeaseClaimer.release's doc comment for why that's\n // still safe rather than blocking every other tab.\n void this.releaseLeadership().catch(() => undefined)\n }\n }\n\n /** Re-check every tracked task right now, bypassing each task's poll interval (e.g. on tab focus). */\n forceCheckAll(): void {\n this.runTickSafely(true)\n }\n\n private claimLeadership(): Promise<PollLeaseClaimResult> {\n return withTabLock(this.options.pollLeaseKey, () => this.pollLease.claim(this.ownerId))\n }\n\n /**\n * Re-confirms that poll leadership is still this tab's — and still the *same continuous\n * tenure* as when `fence` was captured, not just \"is nobody else currently holding it\" (a\n * no-op returning `fence` unchanged when `crossTabPollLeaderElection` is off). Clears `task`'s\n * `finalCheckAttempted` bookkeeping and returns `false` if not — the caller should stop\n * treating this tick's remaining due tasks as network-eligible (though it may still process\n * ones that need no leadership) rather than act on a possibly-stale outcome.\n *\n * A fence mismatch (rather than just an owner-id mismatch) is needed to catch leadership\n * having churned through another tab and back to this one while a slow `handler.check()` was\n * in flight: this tab's lease can expire mid-check, another tab claims it and fully resolves\n * the same task, and that tab's own lease can *also* expire before this tab's stale response\n * comes back — at which point this tab's next claim legitimately succeeds under its own\n * stable owner id (nothing currently holds the lease), even though leadership genuinely\n * changed hands in between. See `PollLeaseClaimResult`.\n */\n private async reconfirmLeadership(\n task: PendingTask<TType>,\n expired: boolean,\n fence: number | undefined,\n ): Promise<number | undefined | false> {\n if (!this.options.crossTabPollLeaderElection) return fence\n if (this.stopped) {\n // stop() has already (best-effort) released this tab's own lease so another tab doesn't\n // have to wait out the full TTL — reclaiming it here, even just to immediately discard a\n // stale response, would write a brand-new full-TTL lease that nothing will ever renew\n // (this poller is stopped), undoing exactly that. Treat \"stopped\" the same as \"leadership\n // lost\" without ever attempting to reclaim.\n if (expired) this.finalCheckAttempted.delete(task.id)\n return false\n }\n let result: PollLeaseClaimResult\n try {\n result = await this.claimLeadership()\n } catch (error) {\n // A rejected claim (e.g. `navigator.locks.request` itself throwing) must not leave this\n // task's finalCheckAttempted entry dangling past this tick — surface the error the same\n // way as before, just without leaking that bookkeeping first.\n if (expired) this.finalCheckAttempted.delete(task.id)\n throw error\n }\n if (result.leader && result.fence === fence) return result.fence\n if (expired) this.finalCheckAttempted.delete(task.id)\n return false\n }\n\n private releaseLeadership(): Promise<void> {\n return withTabLock(this.options.pollLeaseKey, () => {\n this.pollLease.release(this.ownerId)\n })\n }\n\n /** Fires `runTick`, but instead of leaving its promise `void`-called (which would turn an\n * exception thrown by a consumer callback — `onResult`, `onCheckError`, or a `dispatchEvent`\n * listener — into a silent unhandled rejection), re-throws it as an uncaught exception on a\n * fresh microtask. `runTick`'s own `finally` has already flushed the batch and reset\n * `isChecking` by the time this ever runs, so a broken consumer callback can't take the\n * poller down — it just becomes visible the way any other uncaught error in the host\n * environment would be, instead of vanishing. */\n private runTickSafely(force: boolean): void {\n this.runTick(force).catch((error: unknown) => {\n queueMicrotask(() => {\n throw error\n })\n })\n }\n\n /** Reads the freshest snapshot of `task` from the store, in case another tab wrote to it\n * while this tab's `handler.check()` was in flight — narrows, but doesn't eliminate, the\n * window where a concurrent cross-tab write to the same task could be clobbered.\n *\n * Indexes `store.getState().tasks` into a Map keyed by id rather than doing a linear find\n * each call — this is called once per pending/failing task per tick, so a plain find would\n * make a tick O(n²). The cache keys off the `tasks` array reference, which zustand only\n * replaces on an actual write, so it's rebuilt only when the store has genuinely changed. */\n private getLatestTask(task: PendingTask<TType>): PendingTask<TType> {\n const tasks = this.options.store.getState().tasks\n if (this.latestTasksCache?.tasks !== tasks) {\n this.latestTasksCache = { tasks, byId: new Map(tasks.map((t) => [t.id, t])) }\n }\n return this.latestTasksCache.byId.get(task.id) ?? task\n }\n\n /** Applies a whole tick's worth of per-task updates/removals (`patch: null` means \"remove\")\n * in a single read-modify-write, instead of one persisted-storage round trip per task.\n * Reads the freshest persisted list right before writing (same \"never resurrect a task\n * another tab already removed\" guarantee `PendingTaskStore`'s own mutators give) — unless\n * the store's `hasUnpersistedWrites` is set (a write on *any* path for this store, including\n * this store's own direct mutators, failed and hasn't yet been followed by a success), in\n * which case persisted storage is stale relative to this tab's memory, so this flush builds\n * on `store.getState().tasks` instead. Writes through `writeTasks`, which swallows a\n * throwing write and updates that same shared flag — see the comment in `store.ts`. */\n private flushBatch(batch: Map<string, Partial<PendingTask<TType>> | null>): void {\n if (batch.size === 0) return\n\n const base = this.options.store.hasUnpersistedWrites\n ? this.options.store.getState().tasks\n : readPersistedTasks<TType>(this.options.storageKey)\n\n const next: PendingTask<TType>[] = []\n for (const t of base) {\n if (!batch.has(t.id)) {\n next.push(t)\n continue\n }\n const patch = batch.get(t.id)\n if (patch !== null) next.push({ ...t, ...patch })\n }\n\n this.options.store.writeTasks(next)\n }\n\n private async finalize(\n task: PendingTask<TType>,\n detail: Omit<PendingTaskResultEventDetail<TType>, \"task\">,\n handler: PendingTaskHandler<TType> | undefined,\n batch: Map<string, Partial<PendingTask<TType>> | null>,\n ): Promise<void> {\n this.finalCheckAttempted.delete(task.id)\n batch.set(task.id, null)\n\n if (detail.status === \"expired\") return\n\n const silent = detail.status === \"success\" ? handler?.silentOnSuccess : handler?.silentOnFailure\n if (silent) return\n\n let claimed: boolean\n try {\n claimed = this.options.claimResultOnce ? await this.options.claimResultOnce(task) : true\n } catch {\n // claimResultOnce is documented as a cross-tab \"claim once\" gate that can reasonably\n // reject (e.g. a lock timeout) — treat that the same as \"another tab already claimed it\"\n // and skip the dispatch. The removal above already happened, so nothing is left to retry.\n // Unlike this, an exception from `onResult`/`dispatchEvent` below is a real consumer bug\n // and is deliberately left uncaught — `runTickSafely` surfaces it instead of hiding it.\n return\n }\n if (!claimed) return\n\n const fullDetail: PendingTaskResultEventDetail<TType> = { task, ...detail }\n if (this.options.crossTabPollLeaderElection) {\n // Written before onResult/dispatchDomEvent below, deliberately: this relay data is\n // independent of either local callback, and both onResult and a dispatchEvent listener\n // are consumer code that's allowed to throw (surfaced, not swallowed — see the doc\n // comment on runTickSafely). If the relay write came after them, a throwing onResult in\n // just the leader tab would silently strand every *other* tab, which would never learn\n // this result at all (see dispatchRelayedResult) — a single consumer bug in one tab\n // shouldn't be able to take every other open tab down with it.\n writeResultRelay(this.options.resultRelayKey, fullDetail)\n }\n this.options.onResult?.(fullDetail)\n if (this.options.dispatchDomEvent && typeof window !== \"undefined\") {\n window.dispatchEvent(new CustomEvent(this.options.eventName, { detail: fullDetail }))\n }\n }\n\n /**\n * Handles a result relayed from another tab's leader — mirrors finalize()'s own\n * `claimResultOnce` gate and dispatch, so a `claimResultOnce` composed for \"one notification\n * system-wide\" (see its doc comment's \"keep both if you want both properties\") applies\n * uniformly whether this tab detected the result itself or only learned about it via the\n * relay, not just to the narrower direct-detection race `claimResultOnce` guarded before this\n * relay existed.\n *\n * Fired-and-forgotten (`void`-called) from the \"storage\" listener rather than awaited, so it\n * has no `this.stopped` check of its own: `stop()` removes the listener (no *new* relayed\n * result starts one of these after that), but one already in flight when `stop()` is called\n * (e.g. awaiting a slow `claimResultOnce`) still runs to completion — the same tolerance\n * `runTick`'s own doc comment describes for an in-flight tick.\n */\n private async dispatchRelayedResult(detail: PendingTaskResultEventDetail<TType>): Promise<void> {\n let claimed: boolean\n try {\n claimed = this.options.claimResultOnce ? await this.options.claimResultOnce(detail.task) : true\n } catch {\n // Same treatment as finalize()'s own claimResultOnce catch: a reasonable rejection (e.g. a\n // lock timeout) is treated as \"already claimed elsewhere,\" not a bug.\n return\n }\n if (!claimed) return\n\n try {\n this.options.onResult?.(detail)\n if (this.options.dispatchDomEvent && typeof window !== \"undefined\") {\n window.dispatchEvent(new CustomEvent(this.options.eventName, { detail }))\n }\n } catch (error) {\n // An onResult/dispatchEvent throw here is a real consumer bug, same category as the one\n // finalize() deliberately leaves uncaught — but unlike finalize() (called from runTick,\n // which flows into runTickSafely's own catch-and-requeue), this method is invoked directly\n // from a raw \"storage\" event listener with no equivalent wrapper, so it needs its own\n // queueMicrotask rethrow to surface consistently rather than escaping the listener instead.\n queueMicrotask(() => {\n throw error\n })\n }\n }\n\n private async runTick(force: boolean): Promise<void> {\n if (this.stopped) return\n\n if (this.isChecking) {\n this.pendingForce = this.pendingForce || force\n return\n }\n\n const tasks = this.options.store.getState().tasks\n if (tasks.length === 0) return\n\n this.isChecking = true\n const batch = new Map<string, Partial<PendingTask<TType>> | null>()\n try {\n const now = Date.now()\n // Claimed lazily by the first task in this tick that actually needs leadership, then\n // carried forward for the rest of the tick: every task that reaches its own check()\n // re-confirms (and thereby renews) leadership right after, updating `fence` for whichever\n // task comes next, so a later task in the same tick can trust that renewal instead of\n // claiming again immediately beforehand — see the reconfirms below. Stays `undefined` for\n // the whole tick when `crossTabPollLeaderElection` is off.\n let fence: number | undefined\n // Set once any leadership check fails this tick (claim refused, a fence mismatch, or this\n // poller having been stop()ped mid-tick) so every later due task skips straight past its\n // own leadership check instead of redundantly re-attempting one that can only fail the\n // same way again — while still letting tasks that need no leadership at all (pure local\n // expiry bookkeeping, above) keep being processed normally for the rest of this tick.\n let leadershipLost = false\n\n for (const task of tasks) {\n const handler = this.options.registry[task.type]\n const ttlMs = task.ttlMs ?? this.options.defaultTtlMs\n const expired = now - task.startedAt >= ttlMs\n\n if (!handler) {\n // No handler to poll with (e.g. removed/renamed since this task was created) — the\n // only thing we can still do for it is let it expire instead of lingering forever.\n // Pure local bookkeeping, no network call — doesn't need leadership.\n if (expired) {\n await this.finalize(task, { status: \"expired\" }, handler, batch)\n }\n continue\n }\n\n const interval = handler.pollIntervalMs ?? this.options.defaultPollIntervalMs\n const lastChecked = task.lastCheckedAt ?? task.startedAt\n const due = force || now - lastChecked >= interval\n const finalAttemptDone = this.finalCheckAttempted.has(task.id)\n\n if (expired && (!handler.finalCheckOnExpiry || finalAttemptDone)) {\n // Same as above: dropping a task that isn't getting a last look is pure local\n // bookkeeping, no network call, doesn't need leadership.\n await this.finalize(task, { status: \"expired\" }, handler, batch)\n continue\n }\n\n if (!due && !expired) continue\n\n if (expired) {\n this.finalCheckAttempted.add(task.id)\n }\n\n // Cross-tab poll-leader election: claim/renew only right before doing the actual\n // network work — tasks skipped above by the cheap local judgments never touch this,\n // so they don't cost a localStorage round trip or a cross-tab storage-event broadcast\n // just because this tick happened to scan past them. Only the first such task in a tick\n // claims here; every task's post-check reconfirm below already renews the lease (and its\n // fence) for whichever task comes next, so re-claiming again immediately beforehand would\n // just be a redundant localStorage write. Losing the lease here means another tab has\n // already taken over (or this poller has itself been stopped) — skip this and every\n // later due task's network work for the rest of the tick (a new leader, if any, will\n // pick up where this tab left off on its own schedule) without abandoning the tasks\n // after it that need no leadership at all.\n if (this.options.crossTabPollLeaderElection) {\n // Checked unconditionally, before the `fence === undefined` gate below, not inside\n // it: `stop()` can be called from consumer code (e.g. `onResult` calling\n // `poller.stop()`) between two tasks in the same tick, after `fence` already holds an\n // earlier task's still-valid claim. If this check lived inside the `fence ===\n // undefined` branch, it would never run for that later task — `fence` being set\n // would skip the whole block, `handler.check()` would fire anyway (its response\n // still gets discarded by the post-check reconfirm's own `stopped` check, so no data\n // corruption — just a wasted request this check exists to prevent).\n if (leadershipLost || this.stopped) {\n leadershipLost = true\n if (expired) this.finalCheckAttempted.delete(task.id)\n continue\n }\n if (fence === undefined) {\n let claimed: PollLeaseClaimResult\n try {\n claimed = await this.claimLeadership()\n } catch (error) {\n // A rejected claim must not leave this task's finalCheckAttempted entry dangling\n // past this tick — surface the error the same way as before, just without leaking\n // that bookkeeping first (see reconfirmLeadership's matching catch).\n if (expired) this.finalCheckAttempted.delete(task.id)\n throw error\n }\n if (!claimed.leader) {\n leadershipLost = true\n if (expired) this.finalCheckAttempted.delete(task.id)\n continue\n }\n fence = claimed.fence\n }\n }\n\n let result: PendingTaskCheckResult\n try {\n result = await handler.check(task)\n } catch (error) {\n // handler.check()'s own request duration isn't bounded by the lease renewal cadence\n // above — a single slow call can still outlast pollLeaseTtlMs, letting another tab\n // claim leadership (and possibly already resolve this same task) before this one\n // rejects. Re-confirm leadership before acting on what may now be a stale outcome —\n // including an error outcome, since the failure-count/finalize bookkeeping below\n // would otherwise still mutate state a new leader may have already moved past.\n const reconfirmedOnError = await this.reconfirmLeadership(task, expired, fence)\n if (reconfirmedOnError === false) {\n // Reset `fence` to `undefined`, not just `leadershipLost = true`: the pre-check\n // block above only runs its `leadershipLost` short-circuit while `fence ===\n // undefined` (its normal signal for \"haven't claimed yet this tick\"). Leaving\n // `fence` at its old, now-stale value would make that condition false for every\n // later task, skipping the short-circuit entirely and letting them call\n // handler.check() despite `leadershipLost` — exactly the redundant network work\n // that flag exists to prevent.\n leadershipLost = true\n fence = undefined\n continue\n }\n fence = reconfirmedOnError\n\n let intercepted: boolean | void\n try {\n intercepted = this.options.onCheckError?.(error, task)\n } catch (onCheckErrorError) {\n // onCheckError is documented to return a boolean, not throw — a throw here is a\n // consumer callback bug, same category as an `onResult` throw. Don't let it leak\n // this task's `finalCheckAttempted` entry (fall through to the normal handling\n // below as \"not intercepted\") or take down the tick; surface it the same way\n // `runTickSafely` surfaces any other consumer-callback exception.\n queueMicrotask(() => {\n throw onCheckErrorError\n })\n intercepted = false\n }\n\n if (intercepted) {\n // Throttle like a normal check so a caller whose pause is asynchronous (e.g. it\n // still needs to call `stop()` itself) doesn't see this task look overdue again on\n // every following tick in the meantime. This check was intercepted rather than\n // genuinely completed, so it shouldn't consume finalCheckOnExpiry's one last-look\n // allowance — clear the flag so a real final check still happens once polling resumes.\n batch.set(task.id, { lastCheckedAt: now })\n this.finalCheckAttempted.delete(task.id)\n break\n }\n\n if (expired) {\n // The one extra chance finalCheckOnExpiry grants has now been used — expire rather\n // than entering the generic failure-backoff loop.\n await this.finalize(task, { status: \"expired\" }, handler, batch)\n continue\n }\n\n const latest = this.getLatestTask(task)\n const failureCount = (latest.failureCount ?? 0) + 1\n if (failureCount >= this.options.maxFailureCount) {\n await this.finalize(task, { status: \"error\", data: describeError(error) }, handler, batch)\n } else {\n batch.set(task.id, { lastCheckedAt: now, failureCount })\n }\n continue\n }\n\n // Same staleness concern as the catch branch above, for the success path: a late\n // \"pending\" response would otherwise silently overwrite a newer leader's more current\n // progress with older numbers (e.g. a percent-complete counter visibly ticking\n // backward), and a late terminal response could finalize a task a new leader has\n // already moved past.\n const reconfirmedOnSuccess = await this.reconfirmLeadership(task, expired, fence)\n if (reconfirmedOnSuccess === false) {\n // See the matching comment in the catch branch above: `fence` must go back to\n // `undefined` too, or the pre-check block's `leadershipLost` short-circuit never runs\n // for any later task this tick.\n leadershipLost = true\n fence = undefined\n continue\n }\n fence = reconfirmedOnSuccess\n\n if (result.status === \"pending\") {\n const latest = this.getLatestTask(task)\n batch.set(task.id, {\n lastCheckedAt: now,\n failureCount: 0,\n metadata: { ...latest.metadata, ...result.progress },\n })\n if (expired) {\n // finalCheckOnExpiry's one last look still came back pending — expire quietly now.\n // (status is \"expired\" here, so finalize() returns before it could ever throw —\n // no try/catch needed, same as the other expiry finalize calls above.)\n await this.finalize(task, { status: \"expired\" }, handler, batch)\n }\n continue\n }\n\n await this.finalize(task, { status: result.status, data: result.data }, handler, batch)\n }\n } finally {\n this.flushBatch(batch)\n this.isChecking = false\n if (this.pendingForce && !this.stopped) {\n this.pendingForce = false\n this.runTickSafely(true)\n }\n }\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,24 @@
1
+ import { n as PendingTaskPollerOptions } from './engine-ClhasLko.cjs';
2
+ export { m as PendingTaskPoller } from './engine-ClhasLko.cjs';
3
+ import 'zustand';
4
+
5
+ /**
6
+ * Mounts a `PendingTaskPoller` for the lifetime of the component. Re-creates the poller
7
+ * whenever `enabled` or the registry/store identity changes (pass a stable `registry` and
8
+ * `store` — module-level singletons, not literals re-created per render).
9
+ *
10
+ * The engine has no notion of auth/sessions — if the poller should only run while the user
11
+ * is authenticated, compute `enabled` from your own auth state (e.g. `enabled: !!token`) and
12
+ * pass it in; the hook tears the poller down whenever `enabled` goes false.
13
+ *
14
+ * Also forces an immediate re-check when the tab regains visibility, so a task doesn't
15
+ * sit stale for a full `pollTickMs` after the user tabs back in.
16
+ *
17
+ * This hook renders nothing and returns nothing — it's a side-effect-only driver, meant
18
+ * to be mounted once near the app root.
19
+ */
20
+ declare function usePendingTaskPoller<TType extends string = string>(options: PendingTaskPollerOptions<TType> & {
21
+ enabled?: boolean;
22
+ }): void;
23
+
24
+ export { PendingTaskPollerOptions, usePendingTaskPoller };
@@ -0,0 +1,24 @@
1
+ import { n as PendingTaskPollerOptions } from './engine-ClhasLko.js';
2
+ export { m as PendingTaskPoller } from './engine-ClhasLko.js';
3
+ import 'zustand';
4
+
5
+ /**
6
+ * Mounts a `PendingTaskPoller` for the lifetime of the component. Re-creates the poller
7
+ * whenever `enabled` or the registry/store identity changes (pass a stable `registry` and
8
+ * `store` — module-level singletons, not literals re-created per render).
9
+ *
10
+ * The engine has no notion of auth/sessions — if the poller should only run while the user
11
+ * is authenticated, compute `enabled` from your own auth state (e.g. `enabled: !!token`) and
12
+ * pass it in; the hook tears the poller down whenever `enabled` goes false.
13
+ *
14
+ * Also forces an immediate re-check when the tab regains visibility, so a task doesn't
15
+ * sit stale for a full `pollTickMs` after the user tabs back in.
16
+ *
17
+ * This hook renders nothing and returns nothing — it's a side-effect-only driver, meant
18
+ * to be mounted once near the app root.
19
+ */
20
+ declare function usePendingTaskPoller<TType extends string = string>(options: PendingTaskPollerOptions<TType> & {
21
+ enabled?: boolean;
22
+ }): void;
23
+
24
+ export { PendingTaskPollerOptions, usePendingTaskPoller };