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.
- package/README.md +266 -0
- package/README.zh-CN.md +250 -0
- package/dist/engine-ClhasLko.d.cts +362 -0
- package/dist/engine-ClhasLko.d.ts +362 -0
- package/dist/index.cjs +705 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +122 -0
- package/dist/index.d.ts +122 -0
- package/dist/index.js +660 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +572 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +24 -0
- package/dist/react.d.ts +24 -0
- package/dist/react.js +546 -0
- package/dist/react.js.map +1 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# pending-task-kit
|
|
2
|
+
|
|
3
|
+
Framework-agnostic engine for tracking and polling long-running background tasks
|
|
4
|
+
(AI generation jobs, async search, payment confirmation, batch operations, ...)
|
|
5
|
+
that can outlive the page that started them, plus an optional React binding.
|
|
6
|
+
|
|
7
|
+
Extracted from a production app's `PendingTaskNotifier` subsystem. The
|
|
8
|
+
notification channel (toasts, redirects, cache invalidation) is deliberately
|
|
9
|
+
**not** part of this package — you wire that up yourself via `onResult`.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add pending-task-kit zustand
|
|
15
|
+
# React binding also needs react as a peer dep (already true in a React app)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Core concepts
|
|
19
|
+
|
|
20
|
+
- **Task** (`PendingTask`) — `{ id, type, taskId, startedAt, ttlMs?, metadata? }`, one row
|
|
21
|
+
tracked until it resolves or expires. There are no dedicated "owner"/"title"/"link" fields
|
|
22
|
+
— the engine reads nothing off a task except `id`/`type`/`taskId`/`startedAt`/
|
|
23
|
+
`lastCheckedAt`/`failureCount`/`ttlMs` (the last two it maintains itself, as dedicated
|
|
24
|
+
fields precisely so they can't collide with a key your own data happens to use). Anything
|
|
25
|
+
else your app wants attached to a task — a display title, a link, a user/tenant id to scope
|
|
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 your
|
|
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
|
|
30
|
+
below), plus per-type tuning (`pollIntervalMs`, `ttlMs`, `finalCheckOnExpiry`,
|
|
31
|
+
`silentOnSuccess`/`silentOnFailure`).
|
|
32
|
+
- **Registry** (`PendingTaskRegistry`) — a plain `{ [type]: handler }` map.
|
|
33
|
+
- **Store** — a zustand store, persisted to `localStorage`, holding the task list.
|
|
34
|
+
- **Poller** (`PendingTaskPoller`) — the engine: scans tasks on an interval, calls the
|
|
35
|
+
matching handler, and resolves each task to `success`/`failure` (dispatched via
|
|
36
|
+
`onResult`), to `error` (dispatched unless `silentOnFailure`) when `check()` itself kept
|
|
37
|
+
throwing until `maxFailureCount`, or silently to `expired` when the TTL ran out first —
|
|
38
|
+
`error`/`expired` are the engine's own doing, never something a handler returns itself.
|
|
39
|
+
|
|
40
|
+
## Usage (core, no React)
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { createPendingTaskStore, createPendingTaskRegistryBinding, PendingTaskPoller } from "pending-task-kit"
|
|
44
|
+
|
|
45
|
+
type TaskType = "search" | "exportJob"
|
|
46
|
+
|
|
47
|
+
const store = createPendingTaskStore<TaskType>({ storageKey: "my-app-pending-tasks" })
|
|
48
|
+
|
|
49
|
+
const registry = {
|
|
50
|
+
search: {
|
|
51
|
+
// The engine has no notion of auth — check() is a plain closure, so it captures
|
|
52
|
+
// whatever auth your app uses (a token, a cookie-based fetch, etc.) itself.
|
|
53
|
+
check: async (task) => {
|
|
54
|
+
const token = myAuthStore.getState().token
|
|
55
|
+
const res = await fetch(`/api/search/${task.taskId}`, { headers: { Authorization: `Bearer ${token}` } })
|
|
56
|
+
const data = await res.json()
|
|
57
|
+
// The backend's own `data.status` vocabulary is whatever that API defines — it has no
|
|
58
|
+
// relationship to the status you return here.
|
|
59
|
+
if (data.status === "done") return { status: "success", data: { href: `/results/${task.taskId}` } }
|
|
60
|
+
if (data.status === "error") return { status: "failure", data: { message: data.error } }
|
|
61
|
+
return { status: "pending", progress: { percent: data.percent } }
|
|
62
|
+
},
|
|
63
|
+
pollIntervalMs: 3_000,
|
|
64
|
+
ttlMs: 5 * 60_000,
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { addTask, addTaskIfMissing } = createPendingTaskRegistryBinding(store, registry)
|
|
69
|
+
|
|
70
|
+
// Start tracking a task right after kicking off the async job. If tasks need to be scoped
|
|
71
|
+
// to the signed-in user/tenant, put that in `metadata` — see "Scoping tasks" below.
|
|
72
|
+
addTask({
|
|
73
|
+
id: `search-${searchId}`,
|
|
74
|
+
type: "search",
|
|
75
|
+
taskId: searchId,
|
|
76
|
+
startedAt: Date.now(),
|
|
77
|
+
metadata: { userId },
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Drive the polling loop (module-level singleton). Call start()/stop() around your own
|
|
81
|
+
// auth lifecycle — e.g. start() once logged in, stop() on logout.
|
|
82
|
+
const poller = new PendingTaskPoller({
|
|
83
|
+
store,
|
|
84
|
+
registry,
|
|
85
|
+
onResult: (detail) => {
|
|
86
|
+
const data = detail.data as { href?: string; message?: string } | undefined
|
|
87
|
+
if (detail.status === "success") showToast(data?.message ?? "Done", { href: data?.href })
|
|
88
|
+
if (detail.status === "failure") showToast(data?.message ?? "Failed", { variant: "error" })
|
|
89
|
+
// detail.status can also be "error" (check() itself kept failing) — decide separately
|
|
90
|
+
// whether that deserves its own message; "expired" never reaches onResult at all.
|
|
91
|
+
},
|
|
92
|
+
onCheckError: (error) => {
|
|
93
|
+
// Return true for an error that means "stop this tick, don't count it as a normal
|
|
94
|
+
// failure" — e.g. the session ended. The engine doesn't know what an auth error looks
|
|
95
|
+
// like; you decide.
|
|
96
|
+
if (isUnauthorizedError(error)) {
|
|
97
|
+
poller.stop()
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
poller.start()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Usage (React binding)
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
import { usePendingTaskPoller } from "pending-task-kit/react"
|
|
109
|
+
|
|
110
|
+
function PendingTaskNotifier() {
|
|
111
|
+
const token = useAuthStore((s) => s.token)
|
|
112
|
+
|
|
113
|
+
usePendingTaskPoller({
|
|
114
|
+
store,
|
|
115
|
+
registry,
|
|
116
|
+
enabled: !!token, // the hook has no notion of auth — you decide when polling should run
|
|
117
|
+
onResult: (detail) => {
|
|
118
|
+
/* show a toast / navigate / invalidate a query cache */
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Mount `<PendingTaskNotifier />` once near your app root.
|
|
127
|
+
|
|
128
|
+
## Cross-tab poll-leader election (on by default)
|
|
129
|
+
|
|
130
|
+
When multiple tabs share the same store — they already do, since tasks sync across tabs via
|
|
131
|
+
`storage` events — only one of them actually calls `handler.check()` for a given task at a
|
|
132
|
+
time; every other tab skips its own network work for that task entirely. This is
|
|
133
|
+
`crossTabPollLeaderElection`, on by default. It's safe to leave on for single-tab usage too:
|
|
134
|
+
an uncontested poller always successfully claims/renews its own lease, so nothing changes when
|
|
135
|
+
there's no other tab to contend with.
|
|
136
|
+
|
|
137
|
+
Leadership is a renewable, TTL-backed claim (`pollLeaseTtlMs`, default `pollTickMs * 4`), not
|
|
138
|
+
a held lock — a leader that stops renewing (closed, crashed, or frozen in the browser's
|
|
139
|
+
back/forward cache) can't block every other tab forever; the lease just expires and any other
|
|
140
|
+
open tab picks up leadership on its next tick. `stop()` also makes a best-effort attempt to
|
|
141
|
+
release the lease right away if held (fire-and-forget, since `stop()` itself is synchronous —
|
|
142
|
+
an abrupt page unload can still lose the race), so a graceful shutdown usually doesn't make
|
|
143
|
+
other tabs wait out the full TTL either.
|
|
144
|
+
|
|
145
|
+
Since only the leader ever detects a task's completion, its result is relayed to every other
|
|
146
|
+
tab through a second localStorage key (`resultRelayKey`, default
|
|
147
|
+
`` `${storageKey}-result-relay` ``): each other tab fires its own `onResult` and (if that tab
|
|
148
|
+
also has `dispatchDomEvent` on) its own `CustomEvent`, from the relayed data, the same as if it
|
|
149
|
+
had detected the completion itself. If you've composed `claimResultOnce` (see the next
|
|
150
|
+
section), the relayed dispatch on every other tab goes through that same gate as the leader's
|
|
151
|
+
own local one — combining both gets you "only the leader polls" *and* "at most one tab ends up
|
|
152
|
+
notifying," consistently, regardless of which tab happened to detect the result.
|
|
153
|
+
|
|
154
|
+
Only one `PendingTaskPoller` instance should exist per tab per store — a `storage` event never
|
|
155
|
+
fires back in the tab that made the write, so a second poller instance sharing the same store
|
|
156
|
+
in the *same* tab would never receive this relay (or the task-list sync above) at all.
|
|
157
|
+
|
|
158
|
+
If a relayed result could belong to a session that's ended in *this* tab by the time it
|
|
159
|
+
arrives (a different account signed in, a logout) and re-surfacing it here would be wrong,
|
|
160
|
+
gate the receiving side with `acceptRelayedResult`:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const poller = new PendingTaskPoller({
|
|
164
|
+
store,
|
|
165
|
+
registry,
|
|
166
|
+
acceptRelayedResult: (detail) => detail.task.metadata?.userId === myAuthStore.getState().userId,
|
|
167
|
+
})
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Turn cross-tab leader election off entirely only if you have a specific reason every tab must
|
|
171
|
+
independently poll everything:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const poller = new PendingTaskPoller({
|
|
175
|
+
store,
|
|
176
|
+
registry,
|
|
177
|
+
crossTabPollLeaderElection: false,
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Cross-tab duplicate-toast dedupe (optional)
|
|
182
|
+
|
|
183
|
+
`crossTabPollLeaderElection` above stops tabs from duplicating the *polling* itself, and a
|
|
184
|
+
fencing check keeps a slow `check()` call from letting a *different* tab also reach `finalize()`
|
|
185
|
+
for the same task after leadership has moved on mid-request. It doesn't guarantee `onResult`
|
|
186
|
+
fires exactly once system-wide on its own, though — narrower windows remain: your own
|
|
187
|
+
`claimResultOnce` callback awaiting something slow can itself let leadership move to another tab
|
|
188
|
+
before it resolves, which then independently completes the same task and calls its own
|
|
189
|
+
`claimResultOnce`; `withTabLock` degrades to no mutual exclusion at all in a browser without the
|
|
190
|
+
Web Locks API; and a lease write that silently fails (quota exceeded, private-mode Safari) can,
|
|
191
|
+
rarely, let two tabs both believe they're leader. If several tabs can end up processing the same
|
|
192
|
+
completion (any of those, or a forced re-login race), compose the included primitives via
|
|
193
|
+
`claimResultOnce` — this also gates the *relayed* dispatch on every other tab (see above), so it
|
|
194
|
+
gives you a true system-wide guarantee even with leader election on:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { withTabLock, createTtlDedupeCache } from "pending-task-kit"
|
|
198
|
+
|
|
199
|
+
const notified = createTtlDedupeCache("my-app-pending-task-notified", 24 * 60 * 60 * 1000)
|
|
200
|
+
|
|
201
|
+
const poller = new PendingTaskPoller({
|
|
202
|
+
// ...
|
|
203
|
+
claimResultOnce: (task) =>
|
|
204
|
+
withTabLock(`pending-task:${task.id}`, () =>
|
|
205
|
+
notified.claim(`${task.id}:${task.startedAt}`),
|
|
206
|
+
),
|
|
207
|
+
})
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Key the claim on `` `${task.id}:${task.startedAt}` ``, not on `task.id` alone. `id` is
|
|
211
|
+
documented as "stable, globally-unique — re-adding a task with the same id replaces it" (see
|
|
212
|
+
the `PendingTask.id` doc comment), so the *same* id can legitimately front several independent
|
|
213
|
+
runs over time (e.g. a user re-triggering the same paid action twice in one day). A cache
|
|
214
|
+
keyed on bare `task.id` doesn't distinguish those runs: the TTL window has to outlive one
|
|
215
|
+
run's own lifetime — a second tab can legitimately reach the same completion late (a slow
|
|
216
|
+
`claimResultOnce` await, a frozen tab waking up, an expiry-time `finalCheckOnExpiry`), so the
|
|
217
|
+
record must still be there when it does — which means it also spans across a second, unrelated
|
|
218
|
+
run's completion: that second completion's `onResult`/relayed dispatch gets silently swallowed
|
|
219
|
+
as if it were a duplicate of the first. `startedAt` is written fresh each time a *new* run is
|
|
220
|
+
added (see the `addTask` doc comment's replace-on-same-id note), while two tabs racing over
|
|
221
|
+
the *same* run still see the same `startedAt` — so appending it narrows the dedupe to "this
|
|
222
|
+
run" without reopening the cross-tab race this section exists to close.
|
|
223
|
+
|
|
224
|
+
If whatever `claimResultOnce` gates on (or `metadata`/`data` fields in the tasks it tracks) can
|
|
225
|
+
carry PII, call `notified.clear()` on explicit logout the same way you'd call the store's
|
|
226
|
+
`clearAllTasks()` — see "Scoping tasks" below.
|
|
227
|
+
|
|
228
|
+
## Scoping tasks (e.g. to a user/tenant)
|
|
229
|
+
|
|
230
|
+
`PendingTask` has no dedicated "owner" field — the engine doesn't know or care what a task
|
|
231
|
+
"belongs to". If your app needs that (most do), put an identifier of your choosing in
|
|
232
|
+
`metadata` when you `addTask`, and use the store's `pruneTasksBy(predicate)` to drop tasks
|
|
233
|
+
that don't match it — e.g. after a different account logs in:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
store.getState().pruneTasksBy((task) => task.metadata?.userId === currentUserId)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
This package also has no opinion on login/logout more broadly — call `pruneTasksBy` after
|
|
240
|
+
login and `clearAllTasks()` on explicit logout yourself, in whatever auth store you use.
|
|
241
|
+
Skipping `clearAllTasks()` on a *forced* (e.g. 401) logout lets in-flight tasks (like a pending
|
|
242
|
+
payment confirmation) survive a quick re-login — that's an intentional choice to make, not a
|
|
243
|
+
default this package bakes in.
|
|
244
|
+
|
|
245
|
+
If a task's `metadata` or a handler's `PendingTaskCheckResult.data` can carry PII, remember
|
|
246
|
+
this can now also transiently sit in the `resultRelayKey` localStorage entry (see "Cross-tab
|
|
247
|
+
poll-leader election" above) once `crossTabPollLeaderElection` is on — it's overwritten by the
|
|
248
|
+
next result, but nothing clears it proactively. Clear it on explicit logout the same way you'd
|
|
249
|
+
call the store's `clearAllTasks()` or a dedupe cache's `clear()`:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
import { clearResultRelay } from "pending-task-kit"
|
|
253
|
+
|
|
254
|
+
clearResultRelay(resultRelayKey) // same key you passed, or `${storageKey}-result-relay`
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## What's deliberately out of scope
|
|
258
|
+
|
|
259
|
+
- Toast/notification UI (`onResult` is a plain callback — bring your own).
|
|
260
|
+
- Navigation, messages, action labels, cache invalidation — `PendingTaskCheckResult.data`
|
|
261
|
+
is a free-form payload for all of it; `status` is the only field the engine itself reads.
|
|
262
|
+
- Auth/session management entirely — there's no token concept anywhere in the engine.
|
|
263
|
+
`handler.check(task)` is a plain closure, so it captures whatever auth your app uses itself;
|
|
264
|
+
`start()`/`stop()` (or the React binding's `enabled`) are how you gate polling on being
|
|
265
|
+
logged in; `onCheckError` lets you recognize an auth failure and react to it (e.g. call
|
|
266
|
+
`stop()`) without the engine knowing what "unauthorized" means.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# pending-task-kit
|
|
2
|
+
|
|
3
|
+
框架无关的长任务跟踪/轮询引擎(AI 生成任务、异步搜索、支付确认、批量操作等),
|
|
4
|
+
用于处理那些"可能比发起它的页面活得更久"的后台任务,附带一个可选的 React 绑定。
|
|
5
|
+
|
|
6
|
+
从某个生产环境应用的 `PendingTaskNotifier` 子系统中抽取而来。通知渠道
|
|
7
|
+
(toast、跳转、缓存失效)被刻意排除在这个包之外——你需要自己通过 `onResult` 接入。
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add pending-task-kit zustand
|
|
13
|
+
# React 绑定还需要 react 作为 peer dependency(在 React 应用里通常已经有了)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 核心概念
|
|
17
|
+
|
|
18
|
+
- **Task**(`PendingTask`)—— `{ id, type, taskId, startedAt, ttlMs?, metadata? }`,代表一条
|
|
19
|
+
会被持续跟踪、直到 resolve 或过期的记录。它没有专门的"owner"/"title"/"link"字段——引擎
|
|
20
|
+
自身只会读取 `id`/`type`/`taskId`/`startedAt`/`lastCheckedAt`/`failureCount`/`ttlMs`(后两
|
|
21
|
+
者由引擎自己维护,之所以作为独立字段,正是为了不与你自己数据里可能用到的 key 冲突)。
|
|
22
|
+
你的应用想附加到任务上的其它任何东西——展示用的标题、链接、用来做归属的用户/租户
|
|
23
|
+
id——都放进完全自由形式的 `metadata` 里;配合 store 的 `pruneTasksBy` 按它过滤。
|
|
24
|
+
- **Handler**(`PendingTaskHandler`)—— 按 `type` 划分,定义 `check(task)`,负责轮询你的
|
|
25
|
+
后端并返回 `{ status: "pending" | "success" | "failure", progress?, data? }`——`data` 是
|
|
26
|
+
自由形式的负载(一个链接、一条消息,或任何你 `onResult` 需要的东西;见下文),此外还有
|
|
27
|
+
按类型调节的选项(`pollIntervalMs`、`ttlMs`、`finalCheckOnExpiry`、
|
|
28
|
+
`silentOnSuccess`/`silentOnFailure`)。
|
|
29
|
+
- **Registry**(`PendingTaskRegistry`)—— 一个普通的 `{ [type]: handler }` 映射。
|
|
30
|
+
- **Store** —— 一个 zustand store,持久化到 `localStorage`,保存任务列表。
|
|
31
|
+
- **Poller**(`PendingTaskPoller`)—— 引擎本体:按间隔扫描任务,调用对应的 handler,并将
|
|
32
|
+
每个任务归结为 `success`/`failure`(通过 `onResult` 派发)、`error`(当 `check()` 本身
|
|
33
|
+
持续抛错直到达到 `maxFailureCount` 时派发,除非设置了 `silentOnFailure`),或者在 TTL
|
|
34
|
+
先耗尽时静默归结为 `expired`——`error`/`expired` 都是引擎自己的判断,handler 本身永远
|
|
35
|
+
不会返回这两种状态。
|
|
36
|
+
|
|
37
|
+
## 用法(核心,不涉及 React)
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { createPendingTaskStore, createPendingTaskRegistryBinding, PendingTaskPoller } from "pending-task-kit"
|
|
41
|
+
|
|
42
|
+
type TaskType = "search" | "exportJob"
|
|
43
|
+
|
|
44
|
+
const store = createPendingTaskStore<TaskType>({ storageKey: "my-app-pending-tasks" })
|
|
45
|
+
|
|
46
|
+
const registry = {
|
|
47
|
+
search: {
|
|
48
|
+
// 引擎不了解身份认证——check() 只是个普通闭包,所以它会自然捕获你应用自己用的那套
|
|
49
|
+
// 认证方式(一个 token、基于 cookie 的 fetch,等等)。
|
|
50
|
+
check: async (task) => {
|
|
51
|
+
const token = myAuthStore.getState().token
|
|
52
|
+
const res = await fetch(`/api/search/${task.taskId}`, { headers: { Authorization: `Bearer ${token}` } })
|
|
53
|
+
const data = await res.json()
|
|
54
|
+
// 后端自己的 data.status 取值是那个 API 自行定义的——和这里返回的 status 没有任何关系。
|
|
55
|
+
if (data.status === "done") return { status: "success", data: { href: `/results/${task.taskId}` } }
|
|
56
|
+
if (data.status === "error") return { status: "failure", data: { message: data.error } }
|
|
57
|
+
return { status: "pending", progress: { percent: data.percent } }
|
|
58
|
+
},
|
|
59
|
+
pollIntervalMs: 3_000,
|
|
60
|
+
ttlMs: 5 * 60_000,
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { addTask, addTaskIfMissing } = createPendingTaskRegistryBinding(store, registry)
|
|
65
|
+
|
|
66
|
+
// 在异步任务发起后立刻开始跟踪它。如果任务需要按登录用户/租户做归属区分,把这个信息
|
|
67
|
+
// 放进 metadata——见下文"任务归属范围"一节。
|
|
68
|
+
addTask({
|
|
69
|
+
id: `search-${searchId}`,
|
|
70
|
+
type: "search",
|
|
71
|
+
taskId: searchId,
|
|
72
|
+
startedAt: Date.now(),
|
|
73
|
+
metadata: { userId },
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// 驱动轮询循环(模块级单例)。围绕你自己的认证生命周期调用 start()/stop()——比如登录后
|
|
77
|
+
// start(),登出时 stop()。
|
|
78
|
+
const poller = new PendingTaskPoller({
|
|
79
|
+
store,
|
|
80
|
+
registry,
|
|
81
|
+
onResult: (detail) => {
|
|
82
|
+
const data = detail.data as { href?: string; message?: string } | undefined
|
|
83
|
+
if (detail.status === "success") showToast(data?.message ?? "Done", { href: data?.href })
|
|
84
|
+
if (detail.status === "failure") showToast(data?.message ?? "Failed", { variant: "error" })
|
|
85
|
+
// detail.status 也可能是 "error"(check() 自己持续失败)——是否需要单独的提示文案由你
|
|
86
|
+
// 决定;"expired" 永远不会到达 onResult。
|
|
87
|
+
},
|
|
88
|
+
onCheckError: (error) => {
|
|
89
|
+
// 对于那种意味着"终止本轮 tick、不计入正常失败次数"的错误返回 true——比如会话已过期。
|
|
90
|
+
// 引擎不知道认证错误长什么样,由你来判断。
|
|
91
|
+
if (isUnauthorizedError(error)) {
|
|
92
|
+
poller.stop()
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
poller.start()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## 用法(React 绑定)
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import { usePendingTaskPoller } from "pending-task-kit/react"
|
|
104
|
+
|
|
105
|
+
function PendingTaskNotifier() {
|
|
106
|
+
const token = useAuthStore((s) => s.token)
|
|
107
|
+
|
|
108
|
+
usePendingTaskPoller({
|
|
109
|
+
store,
|
|
110
|
+
registry,
|
|
111
|
+
enabled: !!token, // hook 不了解身份认证——由你决定何时应该轮询
|
|
112
|
+
onResult: (detail) => {
|
|
113
|
+
/* 弹 toast / 跳转 / 让某个 query 缓存失效 */
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
在应用根部挂载一次 `<PendingTaskNotifier />` 即可。
|
|
122
|
+
|
|
123
|
+
## 跨标签页轮询选主(默认开启)
|
|
124
|
+
|
|
125
|
+
多个标签页共享同一个 store 时(它们本来就是共享的——任务本身已经通过 `storage` 事件
|
|
126
|
+
跨标签页同步),同一时刻只会有一个标签页真正调用 `handler.check()`;其它标签页对这个
|
|
127
|
+
task 完全不发起任何网络请求。这就是 `crossTabPollLeaderElection`,默认开启。单标签页场景
|
|
128
|
+
下开着它也没有副作用——没有别的标签页竞争时,一个 poller 永远能成功认领/续租自己的
|
|
129
|
+
租约,行为不受影响。
|
|
130
|
+
|
|
131
|
+
选主用的是可续租、带 TTL 的声明(`pollLeaseTtlMs`,默认 `pollTickMs * 4`),不是一直持有
|
|
132
|
+
的锁——停止续租的 leader(被关闭、崩溃,或者被浏览器冻结进前进后退缓存)不会永久卡住
|
|
133
|
+
其它标签页,租约会自然过期,任意还开着的标签页在下一次 tick 就能接管。`stop()` 也会
|
|
134
|
+
尽力(best-effort)在持有租约时立刻发起释放——这是 fire-and-forget 的,因为 `stop()`
|
|
135
|
+
本身是同步 API,页面正好在这时被卸载的话仍可能来不及真正写完——优雅关闭的场景下,
|
|
136
|
+
通常其它标签页不需要等满整个 TTL。
|
|
137
|
+
|
|
138
|
+
因为只有 leader 会检测到任务完成,它的结果会通过第二个 localStorage key
|
|
139
|
+
(`resultRelayKey`,默认 `` `${storageKey}-result-relay` ``)广播给其它每个标签页:每个
|
|
140
|
+
标签页会用广播过来的数据触发自己的 `onResult`,以及(如果那个标签页也开着
|
|
141
|
+
`dispatchDomEvent`)自己的 `CustomEvent`,效果跟那个标签页自己检测到完成一样。如果你
|
|
142
|
+
组合使用了 `claimResultOnce`(见下一节),其它标签页收到广播后的派发也会经过和 leader
|
|
143
|
+
本地派发相同的这道门——两者一起用,就能同时得到"只有 leader 轮询"和"最多一个标签页
|
|
144
|
+
最终发出通知"这两个效果,且不受哪个标签页先检测到结果的影响。
|
|
145
|
+
|
|
146
|
+
同一个标签页、同一个 store 下应该只存在一个 `PendingTaskPoller` 实例——`storage` 事件
|
|
147
|
+
永远不会在发起写入的那个标签页自己身上触发,所以同一个标签页里如果有第二个 poller 实例
|
|
148
|
+
共享同一个 store,它将完全收不到这份广播(也收不到上面提到的任务列表同步)。
|
|
149
|
+
|
|
150
|
+
如果一条广播过来的结果,到达这个标签页时可能已经"过期"(比如中途换了个账号登录、或者
|
|
151
|
+
已经登出了),继续在这个标签页里重新 dispatch 出去就不对了,可以用 `acceptRelayedResult`
|
|
152
|
+
给接收端加一道判断:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
const poller = new PendingTaskPoller({
|
|
156
|
+
store,
|
|
157
|
+
registry,
|
|
158
|
+
acceptRelayedResult: (detail) => detail.task.metadata?.userId === myAuthStore.getState().userId,
|
|
159
|
+
})
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
只有在你确实需要让每个标签页各自轮询全部任务时,才整体关掉跨标签页选主:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const poller = new PendingTaskPoller({
|
|
166
|
+
store,
|
|
167
|
+
registry,
|
|
168
|
+
crossTabPollLeaderElection: false,
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## 跨标签页去重提示(可选)
|
|
173
|
+
|
|
174
|
+
上面的 `crossTabPollLeaderElection` 解决的是"轮询本身不要重复";有了 fencing 校验之后,
|
|
175
|
+
一次很慢的 `check()` 也不会再让 leadership 转手后的另一个标签页也走到同一个任务的
|
|
176
|
+
`finalize()`。但这不代表 `onResult` 在全局范围内保证只触发一次——还残留几个更窄的窗口:
|
|
177
|
+
你自己的 `claimResultOnce` 回调如果本身很慢,它 await 的过程中 leadership 仍可能转手到
|
|
178
|
+
另一个标签页,那个标签页独立完成同一个任务后,会调用它自己的 `claimResultOnce`;在没有
|
|
179
|
+
Web Locks API 的浏览器里,`withTabLock` 会退化为完全不加锁;租约写入偶尔静默失败(比如
|
|
180
|
+
配额超限、Safari 隐私模式)时,也可能让两个标签页都以为自己是 leader。如果多个标签页
|
|
181
|
+
可能同时处理同一个任务的完成事件(以上任意一种竞态,或者强制重新登录导致的竞态),可以
|
|
182
|
+
通过 `claimResultOnce` 组合内置的两个原语——它也会覆盖到其它标签页收到广播后的那次派发
|
|
183
|
+
(见上文),所以即使开着选主,也能拿到真正的全局保证:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { withTabLock, createTtlDedupeCache } from "pending-task-kit"
|
|
187
|
+
|
|
188
|
+
const notified = createTtlDedupeCache("my-app-pending-task-notified", 24 * 60 * 60 * 1000)
|
|
189
|
+
|
|
190
|
+
const poller = new PendingTaskPoller({
|
|
191
|
+
// ...
|
|
192
|
+
claimResultOnce: (task) =>
|
|
193
|
+
withTabLock(`pending-task:${task.id}`, () =>
|
|
194
|
+
notified.claim(`${task.id}:${task.startedAt}`),
|
|
195
|
+
),
|
|
196
|
+
})
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
去重的 key 要用 `` `${task.id}:${task.startedAt}` ``,不能只用 `task.id`。`id` 的文档
|
|
200
|
+
(见 `PendingTask.id` 的 doc 注释)写的是"稳定、全局唯一——用同一个 id 重新 addTask 会替换掉
|
|
201
|
+
原来那条",也就是说同一个 id 完全可以在不同时间点先后对应好几轮互不相关的独立任务
|
|
202
|
+
(比如用户同一天内两次触发同一个付费动作)。只按裸 `task.id` 去重分不清这几轮:
|
|
203
|
+
去重记录的 TTL 必须比单轮任务自己的生命周期还长——另一个标签页完全可能更晚才到达同一次完成
|
|
204
|
+
(`claimResultOnce` 等待耗时、冻结的标签页被唤醒、到期时的 `finalCheckOnExpiry`),记录必须
|
|
205
|
+
在那时还在——这就意味着它同样会覆盖到第二轮、完全独立的那次完成:第二轮的 `onResult`/广播
|
|
206
|
+
派发会被当成"第一轮的重复"直接吞掉。`startedAt` 是每次真正新开一轮任务时才写入的时间戳
|
|
207
|
+
(见 `addTask` doc 注释里"同 id 会替换"的说明),而两个标签页在竞争**同一轮**任务时看到的
|
|
208
|
+
`startedAt` 是相同的——拼上它能把去重粒度收紧到"这一轮",既不会误伤下一轮独立的完成结果,
|
|
209
|
+
也不会打开这一节本来要堵上的跨标签页竞态口子。
|
|
210
|
+
|
|
211
|
+
如果 `claimResultOnce` 判断依据的内容(或者它跟踪的任务的 `metadata`/`data` 字段)可能
|
|
212
|
+
带 PII,在用户主动登出时也调用一下 `notified.clear()`,跟下面"任务归属范围"一节里
|
|
213
|
+
调用 store 的 `clearAllTasks()` 是同一个道理。
|
|
214
|
+
|
|
215
|
+
## 任务归属范围(比如按用户/租户区分)
|
|
216
|
+
|
|
217
|
+
`PendingTask` 没有专门的"owner"字段——引擎不知道也不关心一个任务"属于谁"。如果你的应用
|
|
218
|
+
需要这个(大多数都需要),在 `addTask` 时把你自选的标识符放进 `metadata`,再用 store 的
|
|
219
|
+
`pruneTasksBy(predicate)` 丢弃不匹配的任务——比如切换到另一个账号登录之后:
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
store.getState().pruneTasksBy((task) => task.metadata?.userId === currentUserId)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
这个包在更广泛的登录/登出流程上也没有任何主张——请在你自己的认证 store 里,登录后自行
|
|
226
|
+
调用 `pruneTasksBy`,在用户主动登出时自行调用 `clearAllTasks()`。在*被动*登出(比如收到
|
|
227
|
+
401)时不调用 `clearAllTasks()`,可以让正在进行中的任务(比如一次待确认的支付)在快速
|
|
228
|
+
重新登录后依然存活——这是一个需要你主动做出的选择,而不是这个包默认内置的行为。
|
|
229
|
+
|
|
230
|
+
如果任务的 `metadata` 或 handler 返回的 `PendingTaskCheckResult.data` 可能带 PII,记得
|
|
231
|
+
开启 `crossTabPollLeaderElection` 后,这些内容也会短暂地留在 `resultRelayKey` 这个
|
|
232
|
+
localStorage 条目里(见上面"跨标签页轮询选主"一节)——下一次结果会覆盖它,但没有任何
|
|
233
|
+
东西会主动清理它。在用户主动登出时清掉它,跟调用 store 的 `clearAllTasks()`、去重缓存的
|
|
234
|
+
`clear()` 是同一个道理:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { clearResultRelay } from "pending-task-kit"
|
|
238
|
+
|
|
239
|
+
clearResultRelay(resultRelayKey) // 用你传入的那个 key,没传的话就是 `${storageKey}-result-relay`
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## 刻意排除在范围之外的东西
|
|
243
|
+
|
|
244
|
+
- Toast/通知 UI(`onResult` 只是一个普通回调——UI 部分自己实现)。
|
|
245
|
+
- 跳转、消息文案、操作按钮文案、缓存失效——`PendingTaskCheckResult.data` 是承载这一切的
|
|
246
|
+
自由形式负载;`status` 是引擎自身唯一会读取的字段。
|
|
247
|
+
- 完全不涉及认证/会话管理——引擎里任何地方都没有 token 的概念。`handler.check(task)`
|
|
248
|
+
只是个普通闭包,所以它会自然捕获你应用自己用的认证方式;`start()`/`stop()`(或 React
|
|
249
|
+
绑定里的 `enabled`)是你用来控制"是否应该轮询"的开关;`onCheckError` 让你能够识别出一次
|
|
250
|
+
认证失败并做出反应(比如调用 `stop()`),而引擎本身完全不需要知道"未授权"是什么意思。
|