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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ueaner
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -24,13 +24,18 @@ pnpm add pending-task-kit zustand
|
|
|
24
24
|
fields precisely so they can't collide with a key your own data happens to use). Anything
|
|
25
25
|
else your app wants attached to a task — a display title, a link, a user/tenant id to scope
|
|
26
26
|
by — goes in the fully free-form `metadata`; filter on it with the store's `pruneTasksBy`.
|
|
27
|
-
- **Handler** (`PendingTaskHandler`) — per `type`, defines `check(task)` that polls
|
|
28
|
-
backend and returns `{ status: "pending" | "success" | "failure", progress?, data? }`
|
|
29
|
-
`data` is a free-form payload (a link, a message, whatever your `onResult` needs; see
|
|
27
|
+
- **Handler** (`PendingTaskHandler`) — per `type`, defines `check(task, signal)` that polls
|
|
28
|
+
your backend and returns `{ status: "pending" | "success" | "failure", progress?, data? }`
|
|
29
|
+
— `data` is a free-form payload (a link, a message, whatever your `onResult` needs; see
|
|
30
30
|
below), plus per-type tuning (`pollIntervalMs`, `ttlMs`, `finalCheckOnExpiry`,
|
|
31
|
-
`silentOnSuccess`/`silentOnFailure`
|
|
31
|
+
`silentOnSuccess`/`silentOnFailure`, `retryBackoffMs` — see "Cancellation and retry
|
|
32
|
+
backoff" below). `signal` is an `AbortSignal` you can ignore entirely (existing handlers
|
|
33
|
+
that only take `task` keep working unmodified) or wire into your own request.
|
|
32
34
|
- **Registry** (`PendingTaskRegistry`) — a plain `{ [type]: handler }` map.
|
|
33
|
-
- **Store** — a zustand store, persisted to `localStorage`, holding the task list.
|
|
35
|
+
- **Store** — a zustand store, persisted to `localStorage`, holding the task list. Warns
|
|
36
|
+
once (`console.warn`) if the tracked task count crosses `taskListWarnThreshold` (default
|
|
37
|
+
200) — the whole list is one JSON blob rewritten on every change, so a very large list
|
|
38
|
+
risks the ~5MB per-origin quota.
|
|
34
39
|
- **Poller** (`PendingTaskPoller`) — the engine: scans tasks on an interval, calls the
|
|
35
40
|
matching handler, and resolves each task to `success`/`failure` (dispatched via
|
|
36
41
|
`onResult`), to `error` (dispatched unless `silentOnFailure`) when `check()` itself kept
|
|
@@ -125,6 +130,42 @@ function PendingTaskNotifier() {
|
|
|
125
130
|
|
|
126
131
|
Mount `<PendingTaskNotifier />` once near your app root.
|
|
127
132
|
|
|
133
|
+
## Cancellation, retry backoff, and observability (all optional)
|
|
134
|
+
|
|
135
|
+
`stop()` aborts the `AbortSignal` passed to whichever `handler.check()` call is currently in
|
|
136
|
+
flight, if any — wire it into your own request (`fetch(url, { signal })`) if you want a
|
|
137
|
+
stopped poller to actually cancel outstanding network work instead of only discarding the
|
|
138
|
+
response once it arrives. Losing leadership to another tab is only ever discovered *after*
|
|
139
|
+
`check()` has already settled, so that's the only thing that ever aborts it; handlers that
|
|
140
|
+
ignore `signal` keep working exactly as before.
|
|
141
|
+
|
|
142
|
+
A `check()` that keeps throwing retries on the same fixed `pollIntervalMs`/
|
|
143
|
+
`defaultPollIntervalMs` cadence as everything else by default — set a handler's
|
|
144
|
+
`retryBackoffMs(failureCount)` to back off instead, once a task has actually failed at least
|
|
145
|
+
once:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
const registry = {
|
|
149
|
+
search: {
|
|
150
|
+
check: async (task, signal) => { /* ... */ },
|
|
151
|
+
retryBackoffMs: (failureCount) => Math.min(1_000 * 2 ** failureCount, 60_000), // capped exponential
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`onLeaderChange(isLeader)` fires when this tab's own belief about holding poll leadership
|
|
157
|
+
flips (not once per tick), and `onTick({ durationMs, taskCount })` fires at the end of every
|
|
158
|
+
tick that actually ran — both purely observational, for wiring into your own metrics/logging:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const poller = new PendingTaskPoller({
|
|
162
|
+
store,
|
|
163
|
+
registry,
|
|
164
|
+
onLeaderChange: (isLeader) => metrics.gauge("pending_task_poller.is_leader", isLeader ? 1 : 0),
|
|
165
|
+
onTick: ({ durationMs, taskCount }) => metrics.histogram("pending_task_poller.tick_ms", durationMs),
|
|
166
|
+
})
|
|
167
|
+
```
|
|
168
|
+
|
|
128
169
|
## Cross-tab poll-leader election (on by default)
|
|
129
170
|
|
|
130
171
|
When multiple tabs share the same store — they already do, since tasks sync across tabs via
|
|
@@ -254,6 +295,52 @@ import { clearResultRelay } from "pending-task-kit"
|
|
|
254
295
|
clearResultRelay(resultRelayKey) // same key you passed, or `${storageKey}-result-relay`
|
|
255
296
|
```
|
|
256
297
|
|
|
298
|
+
## Runtime environment notes
|
|
299
|
+
|
|
300
|
+
A grab-bag of behaviors that are intentional trade-offs rather than bugs, collected here so
|
|
301
|
+
they're documented somewhere instead of only in source comments:
|
|
302
|
+
|
|
303
|
+
- **Wall-clock dependent** (`Date.now()` throughout). A clock stepping *backward* just delays
|
|
304
|
+
polling/lease-renewal/dedupe harmlessly. A clock jumping *forward* can make a batch of tasks
|
|
305
|
+
expire silently all at once and make leases/dedupe records expire early — fencing (see
|
|
306
|
+
"Cross-tab poll-leader election") still keeps leadership *correct* through that, just less
|
|
307
|
+
available for a moment.
|
|
308
|
+
- **Leadership rotates routinely, even in foreground tabs.** The lease's TTL defaults to 8s
|
|
309
|
+
(`pollTickMs` × 4), and it's only *renewed* by ticks that actually have a due task to check
|
|
310
|
+
— so with the default 10s `pollIntervalMs` the lease expires between checks anyway and the
|
|
311
|
+
next due tick re-contends for it, in whichever tab gets there first. Backgrounded tabs make
|
|
312
|
+
this much more pronounced: Chrome (and others) throttle a backgrounded tab's timers down to
|
|
313
|
+
as infrequently as once a minute, so a backgrounded leader readily loses leadership to
|
|
314
|
+
another (possibly also backgrounded) tab, and two backgrounded tabs can end up trading
|
|
315
|
+
leadership back and forth. Correctness is unaffected (Web Locks serialize the handoff,
|
|
316
|
+
fencing catches any stale response) — it's purely a responsiveness/battery trade-off already
|
|
317
|
+
inherent to how browsers treat inactive tabs.
|
|
318
|
+
- **`stop()` doesn't hook `pagehide`/`beforeunload` for you.** A hard page unload (closing the
|
|
319
|
+
tab, navigating away) leaves that tick's in-memory batch unflushed and this tab's lease to
|
|
320
|
+
expire on its own TTL (default 8s) rather than being released immediately — the same
|
|
321
|
+
"closed/crashed/frozen tab" case `pollLeaseTtlMs` is already designed to bound.
|
|
322
|
+
- **The React binding's tab-refocus recovery has no non-React equivalent.**
|
|
323
|
+
`usePendingTaskPoller` calls `forceCheckAll()` on `visibilitychange`; if you're using the core
|
|
324
|
+
API directly, wire that up yourself if you want the same "don't sit stale after tabbing back
|
|
325
|
+
in" behavior.
|
|
326
|
+
- **A consumer callback that throws becomes an uncaught exception**, deliberately — surfaced on
|
|
327
|
+
a fresh microtask rather than silently swallowed or left as an unhandled rejection, so a bug
|
|
328
|
+
in your own `onResult`/`onCheckError`/etc. is as visible as any other uncaught error in your
|
|
329
|
+
app, not hidden inside this package.
|
|
330
|
+
- **`zustand`'s own `persist` middleware logs its own `console.warn` on a storage failure** (SSR,
|
|
331
|
+
storage fully unavailable) — that noise comes from zustand itself, not from this package,
|
|
332
|
+
which otherwise degrades storage failures quietly (see `hasUnpersistedWrites`).
|
|
333
|
+
- **A task whose `type` doesn't match any registry entry** (typo'd, or a handler that was
|
|
334
|
+
removed/renamed after the task was created) just sits until its TTL expires, with no warning.
|
|
335
|
+
Parameterize `TType` with a literal string union (rather than leaving it as plain `string`) to
|
|
336
|
+
get exhaustiveness checking on your own registry instead.
|
|
337
|
+
- **The Playwright suite (`test-e2e/`) only runs against Chromium** — Safari/WebKit's
|
|
338
|
+
`navigator.locks` implementation is a known area where behavior could differ; add a WebKit
|
|
339
|
+
project to `playwright.config.ts` if that matters for your users.
|
|
340
|
+
- **This package is pre-1.0.** Per semver convention for `0.x`, a `minor` bump *may* include a
|
|
341
|
+
breaking change — 0.2.0 already exercises that by dropping the CommonJS build (see
|
|
342
|
+
`CHANGELOG.md`), and future `0.x` releases make no stability guarantee either.
|
|
343
|
+
|
|
257
344
|
## What's deliberately out of scope
|
|
258
345
|
|
|
259
346
|
- Toast/notification UI (`onResult` is a plain callback — bring your own).
|
|
@@ -264,3 +351,26 @@ clearResultRelay(resultRelayKey) // same key you passed, or `${storageKey}-resul
|
|
|
264
351
|
`start()`/`stop()` (or the React binding's `enabled`) are how you gate polling on being
|
|
265
352
|
logged in; `onCheckError` lets you recognize an auth failure and react to it (e.g. call
|
|
266
353
|
`stop()`) without the engine knowing what "unauthorized" means.
|
|
354
|
+
|
|
355
|
+
## Contributing
|
|
356
|
+
|
|
357
|
+
`pnpm typecheck && pnpm lint && pnpm test && pnpm build` should all pass; `pnpm test:e2e` runs
|
|
358
|
+
a small real-Chromium Playwright suite (`test-e2e/`) that specifically exercises cross-tab
|
|
359
|
+
`navigator.locks` arbitration and genuine `storage` events — the one thing the jsdom-based
|
|
360
|
+
`pnpm test` suite structurally can't do.
|
|
361
|
+
|
|
362
|
+
This package uses [Changesets](https://github.com/changesets/changesets) for versioning.
|
|
363
|
+
Every change that should land in a release needs a changeset: run `pnpm changeset`, describe
|
|
364
|
+
the change, and pick `patch`/`minor`/`major` — commit the generated file in `.changeset/`
|
|
365
|
+
alongside your change. CI's `changeset status --since` check fails a PR that changed something
|
|
366
|
+
without one, so this isn't just a convention. The check doesn't look at file types, so a
|
|
367
|
+
docs/CI/test-only PR trips it too — `pnpm changeset --empty` is the sanctioned escape hatch
|
|
368
|
+
for those (commit the empty changeset it generates).
|
|
369
|
+
|
|
370
|
+
Releases are tag-triggered (`.github/workflows/release.yml`), not merge-triggered: run
|
|
371
|
+
`pnpm changeset version` (bumps `package.json` and updates `CHANGELOG.md`), commit that, tag the
|
|
372
|
+
commit `vX.Y.Z` matching the version it just bumped to, and push the tag. The release job then
|
|
373
|
+
does a clean checkout of exactly that tag, rebuilds and re-verifies everything from scratch, and
|
|
374
|
+
publishes with `--provenance` — so what gets published is always traceable to a tagged, reviewed
|
|
375
|
+
commit, never to whatever happened to be sitting in a working tree. Needs an `NPM_TOKEN` repo
|
|
376
|
+
secret with publish access.
|
package/README.zh-CN.md
CHANGED
|
@@ -21,13 +21,17 @@ pnpm add pending-task-kit zustand
|
|
|
21
21
|
者由引擎自己维护,之所以作为独立字段,正是为了不与你自己数据里可能用到的 key 冲突)。
|
|
22
22
|
你的应用想附加到任务上的其它任何东西——展示用的标题、链接、用来做归属的用户/租户
|
|
23
23
|
id——都放进完全自由形式的 `metadata` 里;配合 store 的 `pruneTasksBy` 按它过滤。
|
|
24
|
-
- **Handler**(`PendingTaskHandler`)—— 按 `type` 划分,定义 `check(task)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
`silentOnSuccess`/`silentOnFailure
|
|
24
|
+
- **Handler**(`PendingTaskHandler`)—— 按 `type` 划分,定义 `check(task, signal)`,负责
|
|
25
|
+
轮询你的后端并返回 `{ status: "pending" | "success" | "failure", progress?, data? }`——
|
|
26
|
+
`data` 是自由形式的负载(一个链接、一条消息,或任何你 `onResult` 需要的东西;见下文),
|
|
27
|
+
此外还有按类型调节的选项(`pollIntervalMs`、`ttlMs`、`finalCheckOnExpiry`、
|
|
28
|
+
`silentOnSuccess`/`silentOnFailure`、`retryBackoffMs`——见下文"取消与重试 backoff"一节)。
|
|
29
|
+
`signal` 是一个 `AbortSignal`,可以完全不管(只写 `task` 一个参数的现有 handler 不受
|
|
30
|
+
影响),也可以接进你自己的请求里。
|
|
29
31
|
- **Registry**(`PendingTaskRegistry`)—— 一个普通的 `{ [type]: handler }` 映射。
|
|
30
|
-
- **Store** —— 一个 zustand store,持久化到 `localStorage
|
|
32
|
+
- **Store** —— 一个 zustand store,持久化到 `localStorage`,保存任务列表。跟踪的任务数量
|
|
33
|
+
超过 `taskListWarnThreshold`(默认 200)时会 `console.warn` 一次——整份列表是单个 JSON
|
|
34
|
+
blob,每次变化都要整个重写,数量太大会有撞上 ~5MB 单 origin 配额的风险。
|
|
31
35
|
- **Poller**(`PendingTaskPoller`)—— 引擎本体:按间隔扫描任务,调用对应的 handler,并将
|
|
32
36
|
每个任务归结为 `success`/`failure`(通过 `onResult` 派发)、`error`(当 `check()` 本身
|
|
33
37
|
持续抛错直到达到 `maxFailureCount` 时派发,除非设置了 `silentOnFailure`),或者在 TTL
|
|
@@ -120,6 +124,40 @@ function PendingTaskNotifier() {
|
|
|
120
124
|
|
|
121
125
|
在应用根部挂载一次 `<PendingTaskNotifier />` 即可。
|
|
122
126
|
|
|
127
|
+
## 取消、重试 backoff 与观测(均为可选)
|
|
128
|
+
|
|
129
|
+
`stop()` 会中止当前正在飞行中的那次 `handler.check()` 调用所拿到的 `AbortSignal`(如果
|
|
130
|
+
有的话)——接进你自己的请求里(`fetch(url, { signal })`),就能让一个已停止的 poller
|
|
131
|
+
真正取消掉还在进行的网络请求,而不是等响应回来后才丢弃它。leadership 被另一个标签页
|
|
132
|
+
抢走这件事,永远只能在 `check()` 已经 settle 之后才被发现,所以这是唯一会触发中止的
|
|
133
|
+
时机;不理会 `signal` 的 handler 行为不受任何影响。
|
|
134
|
+
|
|
135
|
+
`check()` 持续失败时,默认按和其它情况一样固定的 `pollIntervalMs`/
|
|
136
|
+
`defaultPollIntervalMs` 节奏重试——给 handler 设置 `retryBackoffMs(failureCount)`,可以
|
|
137
|
+
在任务至少失败过一次之后改用这个退避策略:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
const registry = {
|
|
141
|
+
search: {
|
|
142
|
+
check: async (task, signal) => { /* ... */ },
|
|
143
|
+
retryBackoffMs: (failureCount) => Math.min(1_000 * 2 ** failureCount, 60_000), // 有上限的指数退避
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`onLeaderChange(isLeader)` 会在本标签页对"自己是否持有 leadership"的判断发生翻转时
|
|
149
|
+
触发(不是每个 tick 都触发),`onTick({ durationMs, taskCount })` 会在每个真正执行过的
|
|
150
|
+
tick 结束时触发——两者都是纯观测性的,方便接入你自己的监控/日志:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
const poller = new PendingTaskPoller({
|
|
154
|
+
store,
|
|
155
|
+
registry,
|
|
156
|
+
onLeaderChange: (isLeader) => metrics.gauge("pending_task_poller.is_leader", isLeader ? 1 : 0),
|
|
157
|
+
onTick: ({ durationMs, taskCount }) => metrics.histogram("pending_task_poller.tick_ms", durationMs),
|
|
158
|
+
})
|
|
159
|
+
```
|
|
160
|
+
|
|
123
161
|
## 跨标签页轮询选主(默认开启)
|
|
124
162
|
|
|
125
163
|
多个标签页共享同一个 store 时(它们本来就是共享的——任务本身已经通过 `storage` 事件
|
|
@@ -239,6 +277,46 @@ import { clearResultRelay } from "pending-task-kit"
|
|
|
239
277
|
clearResultRelay(resultRelayKey) // 用你传入的那个 key,没传的话就是 `${storageKey}-result-relay`
|
|
240
278
|
```
|
|
241
279
|
|
|
280
|
+
## 运行时环境说明
|
|
281
|
+
|
|
282
|
+
下面这些行为都是刻意的取舍,不是 bug——集中写在这里,而不是只散落在源码注释里:
|
|
283
|
+
|
|
284
|
+
- **全链路依赖墙钟(`Date.now()`)**。时钟往回走只会让轮询/续约/去重变慢一点,无害。
|
|
285
|
+
时钟往前跳可能让一批任务同时静默过期,也可能让 lease/去重记录提前过期——fencing
|
|
286
|
+
(见"跨标签页轮询选主"一节)仍然保证 leadership 判断是*正确*的,只是那一刻可用性会
|
|
287
|
+
打折。
|
|
288
|
+
- **leadership 会例行轮换,前台标签页也一样**。lease 的 TTL 默认 8 秒(`pollTickMs` × 4),
|
|
289
|
+
而且只在*有到期任务要检查*的 tick 上才续约——所以默认 `pollIntervalMs` 为 10 秒时,lease
|
|
290
|
+
在两次 check 之间本来就会过期,下一个 due 的 tick 重新竞争,谁先到谁当 leader。后台标签页
|
|
291
|
+
会让这更明显:Chrome(以及其它浏览器)会把后台标签页的计时器节流到最低每分钟一次,正在
|
|
292
|
+
后台的 leader 很容易把 leadership 让给另一个(可能也在后台的)标签页,甚至两个后台标签页
|
|
293
|
+
反复交替当 leader。正确性不受影响(Web Locks 保证串行化交接,fencing 兜住任何过期响应),
|
|
294
|
+
纯粹是浏览器对非活跃标签页本来就有的性能/省电取舍。
|
|
295
|
+
- **`stop()` 不会自动帮你挂 `pagehide`/`beforeunload`**。硬性卸载页面(关掉标签页、
|
|
296
|
+
跳转离开)会让那一次 tick 的内存 batch 没来得及落盘,这个标签页的 lease 也要等自己的
|
|
297
|
+
TTL(默认 8 秒)自然过期,而不是立刻释放——这和 `pollLeaseTtlMs` 本来就要兜住的
|
|
298
|
+
"标签页被关闭/崩溃/冻结"是同一类场景。
|
|
299
|
+
- **React 绑定的"标签页重新聚焦时恢复"没有非 React 版本**。`usePendingTaskPoller` 会在
|
|
300
|
+
`visibilitychange` 时调用 `forceCheckAll()`;如果你直接用核心 API,想要同样的"切回来
|
|
301
|
+
别让数据卡在旧状态"效果,需要自己接一下这个事件。
|
|
302
|
+
- **consumer 回调抛出的异常会变成一个 uncaught 异常**,这是故意的——在一个新的
|
|
303
|
+
microtask 里重新抛出,而不是被静默吞掉或者变成一个 unhandled rejection,这样你自己
|
|
304
|
+
`onResult`/`onCheckError` 等回调里的 bug 就和你应用里其它 uncaught 错误一样显眼,不会
|
|
305
|
+
被这个包藏起来。
|
|
306
|
+
- **`zustand` 自己的 `persist` 中间件在存储失败时会打自己的 `console.warn`**(SSR、存储
|
|
307
|
+
完全不可用等场景)——这条噪音来自 zustand 自身,不是这个包发出的;这个包自己对存储
|
|
308
|
+
失败的处理是安静降级的(见 `hasUnpersistedWrites`)。
|
|
309
|
+
- **`type` 和 registry 里任何 handler 都对不上的任务**(打错字,或者 handler 在任务创建
|
|
310
|
+
之后被删除/改名了),会一直挂到 TTL 才过期,期间没有任何提示。把 `TType` 参数化成一个
|
|
311
|
+
字面量字符串联合类型(而不是留成普通的 `string`),就能对你自己的 registry 做穷举
|
|
312
|
+
检查。
|
|
313
|
+
- **Playwright 套件(`test-e2e/`)目前只跑 Chromium**——Safari/WebKit 的 `navigator.locks`
|
|
314
|
+
实现是一个已知的可能存在差异的区域;如果这对你的用户重要,可以给
|
|
315
|
+
`playwright.config.ts` 加一个 WebKit project。
|
|
316
|
+
- **这个包还是 0.x 版本**。按 semver 对 0.x 的约定,`minor` 版本号升级*可能*包含破坏性
|
|
317
|
+
改动——0.2.0 就已经动用了这个空间(移除了 CommonJS 构建,见 `CHANGELOG.md`),以后的
|
|
318
|
+
0.x 版本同样不做稳定性保证。
|
|
319
|
+
|
|
242
320
|
## 刻意排除在范围之外的东西
|
|
243
321
|
|
|
244
322
|
- Toast/通知 UI(`onResult` 只是一个普通回调——UI 部分自己实现)。
|
|
@@ -248,3 +326,24 @@ clearResultRelay(resultRelayKey) // 用你传入的那个 key,没传的话就是
|
|
|
248
326
|
只是个普通闭包,所以它会自然捕获你应用自己用的认证方式;`start()`/`stop()`(或 React
|
|
249
327
|
绑定里的 `enabled`)是你用来控制"是否应该轮询"的开关;`onCheckError` 让你能够识别出一次
|
|
250
328
|
认证失败并做出反应(比如调用 `stop()`),而引擎本身完全不需要知道"未授权"是什么意思。
|
|
329
|
+
|
|
330
|
+
## 贡献指南
|
|
331
|
+
|
|
332
|
+
`pnpm typecheck && pnpm lint && pnpm test && pnpm build` 应该全部通过;`pnpm test:e2e`
|
|
333
|
+
会跑一个基于真实 Chromium 的小型 Playwright 套件(`test-e2e/`),专门验证跨标签页
|
|
334
|
+
`navigator.locks` 仲裁和真实的 `storage` 事件——这正是基于 jsdom 的 `pnpm test` 那套
|
|
335
|
+
测试结构性做不到的事。
|
|
336
|
+
|
|
337
|
+
这个包用 [Changesets](https://github.com/changesets/changesets) 管理版本号。任何应该
|
|
338
|
+
出现在发布记录里的改动都需要一个 changeset:运行 `pnpm changeset`,描述改动内容,选
|
|
339
|
+
`patch`/`minor`/`major`——把生成的文件和你的改动一起提交到 `.changeset/` 下。CI 里的
|
|
340
|
+
`changeset status --since` 检查会让"改了代码却没加 changeset"的 PR 直接失败,不只是
|
|
341
|
+
约定。这个检查不看文件类型,纯文档/CI/测试改动的 PR 也会触发——这种情况的正规逃生口是
|
|
342
|
+
`pnpm changeset --empty`(把它生成的空 changeset 一并提交即可)。
|
|
343
|
+
|
|
344
|
+
发布是打 tag 触发的(`.github/workflows/release.yml`),不是合并到 main 就自动触发:
|
|
345
|
+
先跑 `pnpm changeset version`(会更新 `package.json` 和 `CHANGELOG.md`),提交这个改动,
|
|
346
|
+
在这个 commit 上打一个和刚刚升级到的版本号一致的 `vX.Y.Z` tag,再推送这个 tag。发布
|
|
347
|
+
job 会对那个 tag 做一次干净的 checkout,从零重新构建、重新跑一遍所有验证,再带着
|
|
348
|
+
`--provenance` 发布——这样发出去的东西永远能追溯到一个打过 tag、经过审查的 commit,
|
|
349
|
+
而不是工作区里当时恰好放着的任何东西。需要一个有发布权限的 `NPM_TOKEN` 仓库密钥。
|
|
@@ -47,9 +47,34 @@ interface PendingTaskCheckResult {
|
|
|
47
47
|
data?: unknown;
|
|
48
48
|
}
|
|
49
49
|
interface PendingTaskHandler<TType extends string = string> {
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* `signal` is aborted when the poller is `stop()`-ed while this particular call is still in
|
|
52
|
+
* flight (nothing else aborts it — losing leadership to another tab is only ever discovered
|
|
53
|
+
* *after* `check()` has already settled, so there's nothing in-flight left to cancel at that
|
|
54
|
+
* point; see `PendingTaskPoller`'s class doc comment). Wire it into your own request (e.g.
|
|
55
|
+
* `fetch(url, { signal })`) if you want a stopped poller to actually cancel outstanding
|
|
56
|
+
* network work instead of just discarding the response when it eventually arrives. Handlers
|
|
57
|
+
* that ignore the parameter keep working exactly as before — nothing requires reading it.
|
|
58
|
+
*/
|
|
59
|
+
check: (task: PendingTask<TType>, signal: AbortSignal) => Promise<PendingTaskCheckResult>;
|
|
51
60
|
/** How often (ms) this task type is checked. Defaults to the poller's `defaultPollIntervalMs`. */
|
|
52
61
|
pollIntervalMs?: number;
|
|
62
|
+
/**
|
|
63
|
+
* Optional backoff for the failure-retry cadence specifically — a `check()` that keeps
|
|
64
|
+
* throwing, before `maxFailureCount` is reached. Given the just-incremented failure count,
|
|
65
|
+
* return the delay (ms) before the next retry. Only consulted once a task has actually failed
|
|
66
|
+
* at least once; a task that's still cleanly polling (never failed, or already recovered back
|
|
67
|
+
* to `failureCount` 0) keeps using `pollIntervalMs`/`defaultPollIntervalMs` regardless. Leave
|
|
68
|
+
* unset to keep today's behavior: failures retry on the same fixed cadence as everything else.
|
|
69
|
+
*
|
|
70
|
+
* A non-finite or non-positive return (`NaN`, `Infinity`, `0`, negative) falls back to the
|
|
71
|
+
* normal `pollIntervalMs`/`defaultPollIntervalMs` cadence rather than being trusted outright
|
|
72
|
+
* — `0`/negative would otherwise retry on essentially every tick, and `NaN` would make the
|
|
73
|
+
* task never look due again. A throw is treated the same way (as if unset for this task this
|
|
74
|
+
* tick) and surfaced as an uncaught exception rather than either being silently swallowed or
|
|
75
|
+
* taking down the rest of the tick's tasks — the same treatment `onCheckError` gets.
|
|
76
|
+
*/
|
|
77
|
+
retryBackoffMs?: (failureCount: number) => number;
|
|
53
78
|
/** How long (ms) an untracked-to-completion task is kept before being dropped. */
|
|
54
79
|
ttlMs?: number;
|
|
55
80
|
/** Force one last `check()` exactly at TTL expiry instead of silently dropping the task. */
|
|
@@ -69,6 +94,8 @@ interface PendingTaskResultEventDetail<TType extends string = string> {
|
|
|
69
94
|
|
|
70
95
|
declare const DEFAULT_TTL_MS: number;
|
|
71
96
|
declare const DEFAULT_STORAGE_KEY = "pending-tasks";
|
|
97
|
+
/** Default for `CreatePendingTaskStoreOptions.taskListWarnThreshold` — see its doc comment. */
|
|
98
|
+
declare const DEFAULT_TASK_LIST_WARN_THRESHOLD = 200;
|
|
72
99
|
interface PendingTaskStoreState<TType extends string = string> {
|
|
73
100
|
tasks: PendingTask<TType>[];
|
|
74
101
|
addTask: (task: PendingTask<TType>) => void;
|
|
@@ -110,6 +137,17 @@ type PendingTaskStore<TType extends string = string> = UseBoundStore<StoreApi<Pe
|
|
|
110
137
|
interface CreatePendingTaskStoreOptions {
|
|
111
138
|
/** localStorage key. Defaults to `"pending-tasks"`. Must be unique per app if you run multiple stores. */
|
|
112
139
|
storageKey?: string;
|
|
140
|
+
/**
|
|
141
|
+
* Soft warning threshold for the tracked task count, checked on every write. The whole list
|
|
142
|
+
* is persisted as a single JSON blob on every change — a very large list risks the ~5MB
|
|
143
|
+
* per-origin localStorage quota and makes every write (and every other tab's `storage`-event
|
|
144
|
+
* re-parse) slower, but nothing previously surfaced that risk until it actually broke. Once
|
|
145
|
+
* the count crosses this threshold, `console.warn`s exactly once for this store's lifetime
|
|
146
|
+
* (never again after, even if the count keeps climbing) — not a hard limit, tasks keep being
|
|
147
|
+
* tracked normally either way. Defaults to `DEFAULT_TASK_LIST_WARN_THRESHOLD` (200); set to
|
|
148
|
+
* `Infinity` to disable if your app genuinely needs to track more.
|
|
149
|
+
*/
|
|
150
|
+
taskListWarnThreshold?: number;
|
|
113
151
|
}
|
|
114
152
|
declare function isPendingTaskShape(value: unknown): value is PendingTask;
|
|
115
153
|
/** Parses the raw string a zustand-persist localStorage entry holds, tolerating garbage/foreign values. */
|
|
@@ -245,6 +283,26 @@ interface PendingTaskPollerOptions<TType extends string = string> {
|
|
|
245
283
|
* awaiting inline.
|
|
246
284
|
*/
|
|
247
285
|
acceptRelayedResult?: (detail: PendingTaskResultEventDetail<TType>) => boolean;
|
|
286
|
+
/**
|
|
287
|
+
* Fires whenever *this tab's* belief about whether it currently holds poll leadership flips
|
|
288
|
+
* (claimed for the first time, or lost to another tab / this poller being `stop()`-ed) — not
|
|
289
|
+
* on every tick, only on an actual change. Only meaningful (and only ever called) when
|
|
290
|
+
* `crossTabPollLeaderElection` is on; there's no "leader" concept to report when it's off.
|
|
291
|
+
* Purely observational — wire it into your own metrics/logging, the engine's behavior doesn't
|
|
292
|
+
* change based on whether this is set.
|
|
293
|
+
*/
|
|
294
|
+
onLeaderChange?: (isLeader: boolean) => void;
|
|
295
|
+
/**
|
|
296
|
+
* Fires once at the end of every tick that actually ran (ticks skipped because one was
|
|
297
|
+
* already in flight, or because there were no tasks at all, don't count). `taskCount` is how
|
|
298
|
+
* many tasks were in the store when the tick started (not just the due ones); `durationMs`
|
|
299
|
+
* covers the whole tick, including every awaited `handler.check()` call, since this engine
|
|
300
|
+
* processes a tick's due tasks sequentially. Purely observational, same as `onLeaderChange`.
|
|
301
|
+
*/
|
|
302
|
+
onTick?: (info: {
|
|
303
|
+
durationMs: number;
|
|
304
|
+
taskCount: number;
|
|
305
|
+
}) => void;
|
|
248
306
|
}
|
|
249
307
|
/**
|
|
250
308
|
* Framework-agnostic polling engine: scans the store's tasks on an interval, calls the
|
|
@@ -261,10 +319,11 @@ interface PendingTaskPollerOptions<TType extends string = string> {
|
|
|
261
319
|
* Framework bindings (see `./react`) are thin wrappers that call `start()`/`stop()` at the
|
|
262
320
|
* right lifecycle moments and expose `forceCheckAll()` for e.g. tab-focus recovery.
|
|
263
321
|
*
|
|
264
|
-
* Note: `stop()` prevents any *new* tick from starting,
|
|
265
|
-
* `handler.check()`
|
|
266
|
-
*
|
|
267
|
-
*
|
|
322
|
+
* Note: `stop()` prevents any *new* tick from starting, and aborts the `AbortSignal` passed to
|
|
323
|
+
* whatever `handler.check()` call is currently in flight (if any) — but only handlers that
|
|
324
|
+
* actually wire that signal into their own request will see it actually cancelled; one that
|
|
325
|
+
* ignores it still runs to completion. Design handlers to be safe to finish even if the caller
|
|
326
|
+
* has logically "stopped" regardless — e.g. don't assume side effects are undone.
|
|
268
327
|
*/
|
|
269
328
|
declare class PendingTaskPoller<TType extends string = string> {
|
|
270
329
|
private readonly options;
|
|
@@ -279,6 +338,15 @@ declare class PendingTaskPoller<TType extends string = string> {
|
|
|
279
338
|
private pendingForce;
|
|
280
339
|
private stopped;
|
|
281
340
|
private latestTasksCache;
|
|
341
|
+
/** This tab's own most recently reported leadership status, for `onLeaderChange` — tracked
|
|
342
|
+
* here (rather than derived fresh each time from `fence`) purely so that callback fires only
|
|
343
|
+
* on an actual flip, not once per tick it happens to still hold/still lack leadership. */
|
|
344
|
+
private isLeaderTab;
|
|
345
|
+
/** The `AbortController` backing whichever `handler.check()` call is currently in flight, if
|
|
346
|
+
* any — reachable from `stop()` (a synchronous method with no other way to reach into an
|
|
347
|
+
* in-progress `runTick`) so it can actually cancel that request. See `PendingTaskHandler.check`'s
|
|
348
|
+
* doc comment for why this is the *only* thing that ever aborts it. */
|
|
349
|
+
private inFlightAbortController;
|
|
282
350
|
/** Task ids that already got their one `finalCheckOnExpiry` attempt, so a repeatedly-failing
|
|
283
351
|
* final check doesn't get retried every tick. Reset on process restart — worst case that
|
|
284
352
|
* costs one extra check, never an infinite retry loop.
|
|
@@ -295,6 +363,14 @@ declare class PendingTaskPoller<TType extends string = string> {
|
|
|
295
363
|
/** Re-check every tracked task right now, bypassing each task's poll interval (e.g. on tab focus). */
|
|
296
364
|
forceCheckAll(): void;
|
|
297
365
|
private claimLeadership;
|
|
366
|
+
/** Updates `isLeaderTab` and fires `onLeaderChange`, but only on an actual flip — see that
|
|
367
|
+
* option's doc comment, including the "only ever called when `crossTabPollLeaderElection` is
|
|
368
|
+
* on" part, which this enforces itself rather than relying on every call site to remember to
|
|
369
|
+
* guard it (a call site that forgot would otherwise be a real, undetected bug — the whole
|
|
370
|
+
* reason this guard lives here instead of at each of this method's several call sites). Safe
|
|
371
|
+
* to call redundantly (e.g. after every successful claim/reconfirm in a tick, not just the
|
|
372
|
+
* first) since a no-op call is just an equality check. */
|
|
373
|
+
private setLeaderStatus;
|
|
298
374
|
/**
|
|
299
375
|
* Re-confirms that poll leadership is still this tab's — and still the *same continuous
|
|
300
376
|
* tenure* as when `fence` was captured, not just "is nobody else currently holding it" (a
|
|
@@ -359,4 +435,4 @@ declare class PendingTaskPoller<TType extends string = string> {
|
|
|
359
435
|
private runTick;
|
|
360
436
|
}
|
|
361
437
|
|
|
362
|
-
export { type CreatePendingTaskStoreOptions as C, DEFAULT_MAX_FAILURE_COUNT as D, type PendingTaskStore as P, type PendingTaskRegistry as a, type PendingTask as b, type PendingTaskResultEventDetail as c, DEFAULT_POLL_INTERVAL_MS as d, DEFAULT_POLL_LEASE_TTL_MULTIPLIER as e, DEFAULT_POLL_TICK_MS as f, DEFAULT_RESULT_EVENT as g, DEFAULT_STORAGE_KEY as h,
|
|
438
|
+
export { type CreatePendingTaskStoreOptions as C, DEFAULT_MAX_FAILURE_COUNT as D, type PendingTaskStore as P, type PendingTaskRegistry as a, type PendingTask as b, type PendingTaskResultEventDetail as c, DEFAULT_POLL_INTERVAL_MS as d, DEFAULT_POLL_LEASE_TTL_MULTIPLIER as e, DEFAULT_POLL_TICK_MS as f, DEFAULT_RESULT_EVENT as g, DEFAULT_STORAGE_KEY as h, DEFAULT_TASK_LIST_WARN_THRESHOLD as i, DEFAULT_TTL_MS as j, type PendingTaskCheckResult as k, type PendingTaskHandler as l, type PendingTaskMetadata as m, PendingTaskPoller as n, type PendingTaskPollerOptions as o, type PendingTaskResultStatus as p, type PendingTaskStatus as q, type PendingTaskStoreState as r, createPendingTaskStore as s, isPendingTaskShape as t, parseTasksFromStorageValue as u };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { P as PendingTaskStore, a as PendingTaskRegistry, b as PendingTask, c as PendingTaskResultEventDetail } from './engine-
|
|
2
|
-
export { C as CreatePendingTaskStoreOptions, D as DEFAULT_MAX_FAILURE_COUNT, d as DEFAULT_POLL_INTERVAL_MS, e as DEFAULT_POLL_LEASE_TTL_MULTIPLIER, f as DEFAULT_POLL_TICK_MS, g as DEFAULT_RESULT_EVENT, h as DEFAULT_STORAGE_KEY, i as
|
|
1
|
+
import { P as PendingTaskStore, a as PendingTaskRegistry, b as PendingTask, c as PendingTaskResultEventDetail } from './engine-BJ7pokhW.js';
|
|
2
|
+
export { C as CreatePendingTaskStoreOptions, D as DEFAULT_MAX_FAILURE_COUNT, d as DEFAULT_POLL_INTERVAL_MS, e as DEFAULT_POLL_LEASE_TTL_MULTIPLIER, f as DEFAULT_POLL_TICK_MS, g as DEFAULT_RESULT_EVENT, h as DEFAULT_STORAGE_KEY, i as DEFAULT_TASK_LIST_WARN_THRESHOLD, j as DEFAULT_TTL_MS, k as PendingTaskCheckResult, l as PendingTaskHandler, m as PendingTaskMetadata, n as PendingTaskPoller, o as PendingTaskPollerOptions, p as PendingTaskResultStatus, q as PendingTaskStatus, r as PendingTaskStoreState, s as createPendingTaskStore, t as isPendingTaskShape, u as parseTasksFromStorageValue } from './engine-BJ7pokhW.js';
|
|
3
3
|
import 'zustand';
|
|
4
4
|
|
|
5
5
|
/**
|