pending-task-kit 0.1.0 → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +115 -5
- package/README.zh-CN.md +105 -6
- package/dist/{engine-ClhasLko.d.cts → engine-BJ7pokhW.d.ts} +82 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +127 -39
- package/dist/index.js.map +1 -1
- package/dist/react.d.ts +2 -2
- package/dist/react.js +78 -13
- package/dist/react.js.map +1 -1
- package/package.json +40 -15
- package/dist/engine-ClhasLko.d.ts +0 -362
- package/dist/index.cjs +0 -705
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -122
- package/dist/react.cjs +0 -572
- package/dist/react.cjs.map +0 -1
- package/dist/react.d.cts +0 -24
package/dist/index.js
CHANGED
|
@@ -1,8 +1,37 @@
|
|
|
1
1
|
// src/store.ts
|
|
2
2
|
import { create } from "zustand";
|
|
3
3
|
import { createJSONStorage, persist } from "zustand/middleware";
|
|
4
|
+
|
|
5
|
+
// src/safe-storage.ts
|
|
6
|
+
function safeGetItem(key) {
|
|
7
|
+
try {
|
|
8
|
+
if (typeof localStorage === "undefined") return null;
|
|
9
|
+
return localStorage.getItem(key);
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function safeSetItem(key, value) {
|
|
15
|
+
try {
|
|
16
|
+
if (typeof localStorage === "undefined") return false;
|
|
17
|
+
localStorage.setItem(key, value);
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function safeRemoveItem(key) {
|
|
24
|
+
try {
|
|
25
|
+
if (typeof localStorage === "undefined") return;
|
|
26
|
+
localStorage.removeItem(key);
|
|
27
|
+
} catch {
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/store.ts
|
|
4
32
|
var DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
5
33
|
var DEFAULT_STORAGE_KEY = "pending-tasks";
|
|
34
|
+
var DEFAULT_TASK_LIST_WARN_THRESHOLD = 200;
|
|
6
35
|
function isPendingTaskShape(value) {
|
|
7
36
|
if (!value || typeof value !== "object") return false;
|
|
8
37
|
const t = value;
|
|
@@ -20,13 +49,25 @@ function parseTasksFromStorageValue(value) {
|
|
|
20
49
|
}
|
|
21
50
|
}
|
|
22
51
|
function readPersistedTasks(storageKey) {
|
|
23
|
-
|
|
24
|
-
return parseTasksFromStorageValue(localStorage.getItem(storageKey));
|
|
52
|
+
return parseTasksFromStorageValue(safeGetItem(storageKey));
|
|
25
53
|
}
|
|
26
54
|
function createPendingTaskStore(options = {}) {
|
|
27
55
|
const storageKey = options.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
56
|
+
const taskListWarnThreshold = options.taskListWarnThreshold === void 0 || Number.isNaN(options.taskListWarnThreshold) ? DEFAULT_TASK_LIST_WARN_THRESHOLD : options.taskListWarnThreshold;
|
|
28
57
|
const readPersisted = () => readPersistedTasks(storageKey);
|
|
58
|
+
let hasWarnedAboutTaskListSize = false;
|
|
59
|
+
const warnIfTaskListTooLarge = (length) => {
|
|
60
|
+
if (hasWarnedAboutTaskListSize || length <= taskListWarnThreshold) return;
|
|
61
|
+
hasWarnedAboutTaskListSize = true;
|
|
62
|
+
try {
|
|
63
|
+
console.warn(
|
|
64
|
+
`pending-task-kit: tracking ${length} tasks for storageKey "${storageKey}", past the soft warning threshold of ${taskListWarnThreshold}. The whole list is persisted as a single localStorage entry on every change \u2014 a very large list risks the ~5MB per-origin quota and slows down every write. Consider pruning stale tasks more aggressively (see pruneTasksBy), or pass a higher taskListWarnThreshold if this app genuinely needs to track this many.`
|
|
65
|
+
);
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
};
|
|
29
69
|
const writeTasks = (tasks) => {
|
|
70
|
+
warnIfTaskListTooLarge(tasks.length);
|
|
30
71
|
try {
|
|
31
72
|
useStore.setState({ tasks });
|
|
32
73
|
useStore.hasUnpersistedWrites = false;
|
|
@@ -60,7 +101,15 @@ function createPendingTaskStore(options = {}) {
|
|
|
60
101
|
{
|
|
61
102
|
name: storageKey,
|
|
62
103
|
storage: createJSONStorage(() => localStorage),
|
|
63
|
-
partialize: (state) => ({ tasks: state.tasks })
|
|
104
|
+
partialize: (state) => ({ tasks: state.tasks }),
|
|
105
|
+
// The one path that bypasses `writeTasks` (and so its size-warning check) entirely:
|
|
106
|
+
// zustand's own initial rehydrate-from-storage on store creation calls its internal
|
|
107
|
+
// `setState` directly, not through `writeTasks`. Without this, an app that starts up
|
|
108
|
+
// with an already-oversized persisted list would only ever get warned on its *next*
|
|
109
|
+
// mutation, not on load — the case this app most needs the warning for.
|
|
110
|
+
onRehydrateStorage: () => (state) => {
|
|
111
|
+
if (state) warnIfTaskListTooLarge(state.tasks.length);
|
|
112
|
+
}
|
|
64
113
|
}
|
|
65
114
|
)
|
|
66
115
|
);
|
|
@@ -88,32 +137,6 @@ function createPendingTaskRegistryBinding(store, registry) {
|
|
|
88
137
|
return { addTask, addTaskIfMissing };
|
|
89
138
|
}
|
|
90
139
|
|
|
91
|
-
// src/safe-storage.ts
|
|
92
|
-
function safeGetItem(key) {
|
|
93
|
-
try {
|
|
94
|
-
if (typeof localStorage === "undefined") return null;
|
|
95
|
-
return localStorage.getItem(key);
|
|
96
|
-
} catch {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
function safeSetItem(key, value) {
|
|
101
|
-
try {
|
|
102
|
-
if (typeof localStorage === "undefined") return false;
|
|
103
|
-
localStorage.setItem(key, value);
|
|
104
|
-
return true;
|
|
105
|
-
} catch {
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
function safeRemoveItem(key) {
|
|
110
|
-
try {
|
|
111
|
-
if (typeof localStorage === "undefined") return;
|
|
112
|
-
localStorage.removeItem(key);
|
|
113
|
-
} catch {
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
140
|
// src/poll-lease.ts
|
|
118
141
|
function readLease(storageKey) {
|
|
119
142
|
const raw = safeGetItem(storageKey);
|
|
@@ -219,6 +242,10 @@ var PendingTaskPoller = class {
|
|
|
219
242
|
this.isChecking = false;
|
|
220
243
|
this.pendingForce = false;
|
|
221
244
|
this.stopped = false;
|
|
245
|
+
/** This tab's own most recently reported leadership status, for `onLeaderChange` — tracked
|
|
246
|
+
* here (rather than derived fresh each time from `fence`) purely so that callback fires only
|
|
247
|
+
* on an actual flip, not once per tick it happens to still hold/still lack leadership. */
|
|
248
|
+
this.isLeaderTab = false;
|
|
222
249
|
/** Task ids that already got their one `finalCheckOnExpiry` attempt, so a repeatedly-failing
|
|
223
250
|
* final check doesn't get retried every tick. Reset on process restart — worst case that
|
|
224
251
|
* costs one extra check, never an infinite retry loop.
|
|
@@ -248,7 +275,9 @@ var PendingTaskPoller = class {
|
|
|
248
275
|
onResult: options.onResult,
|
|
249
276
|
onCheckError: options.onCheckError,
|
|
250
277
|
claimResultOnce: options.claimResultOnce,
|
|
251
|
-
acceptRelayedResult: options.acceptRelayedResult
|
|
278
|
+
acceptRelayedResult: options.acceptRelayedResult,
|
|
279
|
+
onLeaderChange: options.onLeaderChange,
|
|
280
|
+
onTick: options.onTick
|
|
252
281
|
};
|
|
253
282
|
this.ownerId = generatePollOwnerId();
|
|
254
283
|
this.pollLease = createPollLeaseClaimer(this.options.pollLeaseKey, this.options.pollLeaseTtlMs);
|
|
@@ -291,6 +320,7 @@ var PendingTaskPoller = class {
|
|
|
291
320
|
stop() {
|
|
292
321
|
this.stopped = true;
|
|
293
322
|
this.pendingForce = false;
|
|
323
|
+
this.inFlightAbortController?.abort();
|
|
294
324
|
if (this.intervalId !== void 0) {
|
|
295
325
|
clearInterval(this.intervalId);
|
|
296
326
|
this.intervalId = void 0;
|
|
@@ -301,6 +331,7 @@ var PendingTaskPoller = class {
|
|
|
301
331
|
}
|
|
302
332
|
if (this.options.crossTabPollLeaderElection) {
|
|
303
333
|
void this.releaseLeadership().catch(() => void 0);
|
|
334
|
+
this.setLeaderStatus(false);
|
|
304
335
|
}
|
|
305
336
|
}
|
|
306
337
|
/** Re-check every tracked task right now, bypassing each task's poll interval (e.g. on tab focus). */
|
|
@@ -310,6 +341,25 @@ var PendingTaskPoller = class {
|
|
|
310
341
|
claimLeadership() {
|
|
311
342
|
return withTabLock(this.options.pollLeaseKey, () => this.pollLease.claim(this.ownerId));
|
|
312
343
|
}
|
|
344
|
+
/** Updates `isLeaderTab` and fires `onLeaderChange`, but only on an actual flip — see that
|
|
345
|
+
* option's doc comment, including the "only ever called when `crossTabPollLeaderElection` is
|
|
346
|
+
* on" part, which this enforces itself rather than relying on every call site to remember to
|
|
347
|
+
* guard it (a call site that forgot would otherwise be a real, undetected bug — the whole
|
|
348
|
+
* reason this guard lives here instead of at each of this method's several call sites). Safe
|
|
349
|
+
* to call redundantly (e.g. after every successful claim/reconfirm in a tick, not just the
|
|
350
|
+
* first) since a no-op call is just an equality check. */
|
|
351
|
+
setLeaderStatus(isLeader) {
|
|
352
|
+
if (!this.options.crossTabPollLeaderElection) return;
|
|
353
|
+
if (this.isLeaderTab === isLeader) return;
|
|
354
|
+
this.isLeaderTab = isLeader;
|
|
355
|
+
try {
|
|
356
|
+
this.options.onLeaderChange?.(isLeader);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
queueMicrotask(() => {
|
|
359
|
+
throw error;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
313
363
|
/**
|
|
314
364
|
* Re-confirms that poll leadership is still this tab's — and still the *same continuous
|
|
315
365
|
* tenure* as when `fence` was captured, not just "is nobody else currently holding it" (a
|
|
@@ -327,11 +377,11 @@ var PendingTaskPoller = class {
|
|
|
327
377
|
* changed hands in between. See `PollLeaseClaimResult`.
|
|
328
378
|
*/
|
|
329
379
|
async reconfirmLeadership(task, expired, fence) {
|
|
330
|
-
if (!this.options.crossTabPollLeaderElection) return fence;
|
|
331
380
|
if (this.stopped) {
|
|
332
381
|
if (expired) this.finalCheckAttempted.delete(task.id);
|
|
333
382
|
return false;
|
|
334
383
|
}
|
|
384
|
+
if (!this.options.crossTabPollLeaderElection) return fence;
|
|
335
385
|
let result;
|
|
336
386
|
try {
|
|
337
387
|
result = await this.claimLeadership();
|
|
@@ -465,8 +515,8 @@ var PendingTaskPoller = class {
|
|
|
465
515
|
if (tasks.length === 0) return;
|
|
466
516
|
this.isChecking = true;
|
|
467
517
|
const batch = /* @__PURE__ */ new Map();
|
|
518
|
+
const now = Date.now();
|
|
468
519
|
try {
|
|
469
|
-
const now = Date.now();
|
|
470
520
|
let fence;
|
|
471
521
|
let leadershipLost = false;
|
|
472
522
|
for (const task of tasks) {
|
|
@@ -479,7 +529,19 @@ var PendingTaskPoller = class {
|
|
|
479
529
|
}
|
|
480
530
|
continue;
|
|
481
531
|
}
|
|
482
|
-
const
|
|
532
|
+
const failureCount = task.failureCount ?? 0;
|
|
533
|
+
let backoffMs;
|
|
534
|
+
if (failureCount > 0) {
|
|
535
|
+
try {
|
|
536
|
+
backoffMs = handler.retryBackoffMs?.(failureCount);
|
|
537
|
+
} catch (retryBackoffMsError) {
|
|
538
|
+
queueMicrotask(() => {
|
|
539
|
+
throw retryBackoffMsError;
|
|
540
|
+
});
|
|
541
|
+
backoffMs = void 0;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const interval = backoffMs !== void 0 && Number.isFinite(backoffMs) && backoffMs > 0 ? backoffMs : handler.pollIntervalMs ?? this.options.defaultPollIntervalMs;
|
|
483
545
|
const lastChecked = task.lastCheckedAt ?? task.startedAt;
|
|
484
546
|
const due = force || now - lastChecked >= interval;
|
|
485
547
|
const finalAttemptDone = this.finalCheckAttempted.has(task.id);
|
|
@@ -505,25 +567,31 @@ var PendingTaskPoller = class {
|
|
|
505
567
|
if (expired) this.finalCheckAttempted.delete(task.id);
|
|
506
568
|
throw error;
|
|
507
569
|
}
|
|
508
|
-
if (!claimed.leader) {
|
|
570
|
+
if (this.stopped || !claimed.leader) {
|
|
509
571
|
leadershipLost = true;
|
|
572
|
+
this.setLeaderStatus(false);
|
|
510
573
|
if (expired) this.finalCheckAttempted.delete(task.id);
|
|
511
574
|
continue;
|
|
512
575
|
}
|
|
513
576
|
fence = claimed.fence;
|
|
577
|
+
this.setLeaderStatus(true);
|
|
514
578
|
}
|
|
515
579
|
}
|
|
516
580
|
let result;
|
|
581
|
+
const abortController = new AbortController();
|
|
582
|
+
this.inFlightAbortController = abortController;
|
|
517
583
|
try {
|
|
518
|
-
result = await handler.check(task);
|
|
584
|
+
result = await handler.check(task, abortController.signal);
|
|
519
585
|
} catch (error) {
|
|
520
586
|
const reconfirmedOnError = await this.reconfirmLeadership(task, expired, fence);
|
|
521
587
|
if (reconfirmedOnError === false) {
|
|
522
588
|
leadershipLost = true;
|
|
523
589
|
fence = void 0;
|
|
590
|
+
this.setLeaderStatus(false);
|
|
524
591
|
continue;
|
|
525
592
|
}
|
|
526
593
|
fence = reconfirmedOnError;
|
|
594
|
+
this.setLeaderStatus(true);
|
|
527
595
|
let intercepted;
|
|
528
596
|
try {
|
|
529
597
|
intercepted = this.options.onCheckError?.(error, task);
|
|
@@ -543,21 +611,25 @@ var PendingTaskPoller = class {
|
|
|
543
611
|
continue;
|
|
544
612
|
}
|
|
545
613
|
const latest = this.getLatestTask(task);
|
|
546
|
-
const
|
|
547
|
-
if (
|
|
614
|
+
const failureCount2 = (latest.failureCount ?? 0) + 1;
|
|
615
|
+
if (failureCount2 >= this.options.maxFailureCount) {
|
|
548
616
|
await this.finalize(task, { status: "error", data: describeError(error) }, handler, batch);
|
|
549
617
|
} else {
|
|
550
|
-
batch.set(task.id, { lastCheckedAt: now, failureCount });
|
|
618
|
+
batch.set(task.id, { lastCheckedAt: now, failureCount: failureCount2 });
|
|
551
619
|
}
|
|
552
620
|
continue;
|
|
621
|
+
} finally {
|
|
622
|
+
if (this.inFlightAbortController === abortController) this.inFlightAbortController = void 0;
|
|
553
623
|
}
|
|
554
624
|
const reconfirmedOnSuccess = await this.reconfirmLeadership(task, expired, fence);
|
|
555
625
|
if (reconfirmedOnSuccess === false) {
|
|
556
626
|
leadershipLost = true;
|
|
557
627
|
fence = void 0;
|
|
628
|
+
this.setLeaderStatus(false);
|
|
558
629
|
continue;
|
|
559
630
|
}
|
|
560
631
|
fence = reconfirmedOnSuccess;
|
|
632
|
+
this.setLeaderStatus(true);
|
|
561
633
|
if (result.status === "pending") {
|
|
562
634
|
const latest = this.getLatestTask(task);
|
|
563
635
|
batch.set(task.id, {
|
|
@@ -573,8 +645,23 @@ var PendingTaskPoller = class {
|
|
|
573
645
|
await this.finalize(task, { status: result.status, data: result.data }, handler, batch);
|
|
574
646
|
}
|
|
575
647
|
} finally {
|
|
576
|
-
this.flushBatch(batch);
|
|
577
648
|
this.isChecking = false;
|
|
649
|
+
try {
|
|
650
|
+
this.flushBatch(batch);
|
|
651
|
+
} catch (error) {
|
|
652
|
+
queueMicrotask(() => {
|
|
653
|
+
throw error;
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
if (this.options.onTick) {
|
|
657
|
+
try {
|
|
658
|
+
this.options.onTick({ durationMs: Date.now() - now, taskCount: tasks.length });
|
|
659
|
+
} catch (error) {
|
|
660
|
+
queueMicrotask(() => {
|
|
661
|
+
throw error;
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
578
665
|
if (this.pendingForce && !this.stopped) {
|
|
579
666
|
this.pendingForce = false;
|
|
580
667
|
this.runTickSafely(true);
|
|
@@ -643,6 +730,7 @@ export {
|
|
|
643
730
|
DEFAULT_POLL_TICK_MS,
|
|
644
731
|
DEFAULT_RESULT_EVENT,
|
|
645
732
|
DEFAULT_STORAGE_KEY,
|
|
733
|
+
DEFAULT_TASK_LIST_WARN_THRESHOLD,
|
|
646
734
|
DEFAULT_TTL_MS,
|
|
647
735
|
PendingTaskPoller,
|
|
648
736
|
clearResultRelay,
|