@vobs/resource 0.1.0 → 1.0.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 +1 -1
- package/README.md +56 -0
- package/package.json +14 -33
- package/src/boundary.ts +52 -0
- package/src/index.ts +35 -0
- package/src/plugin.ts +70 -0
- package/src/resource.test.ts +460 -0
- package/src/resource.ts +621 -0
- package/dist/index.d.ts +0 -118
- package/dist/index.js +0 -308
- package/dist/loader.d.ts +0 -32
- package/dist/loader.js +0 -93
package/src/resource.ts
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { createOwner, effect, getCurrentOwner, state, type Signal } from '@vobs/reactivity'
|
|
2
|
+
|
|
3
|
+
export type ResourceKey = readonly unknown[]
|
|
4
|
+
export type ResourceKeySource = ResourceKey | Signal<ResourceKey> | (() => ResourceKey)
|
|
5
|
+
export type ResourceFetcher<T> = (signal: AbortSignal) => T | PromiseLike<T>
|
|
6
|
+
export type ResourceCacheStrategy = 'cache-first' | 'stale-while-revalidate'
|
|
7
|
+
|
|
8
|
+
export interface Resource<T> {
|
|
9
|
+
readonly key: ResourceKey | undefined
|
|
10
|
+
readonly data: Signal<T | null>
|
|
11
|
+
readonly error: Signal<Error | null>
|
|
12
|
+
readonly loading: Signal<boolean>
|
|
13
|
+
dispose(): void
|
|
14
|
+
refetch(): Promise<T>
|
|
15
|
+
prefetch(): Promise<T>
|
|
16
|
+
invalidate(): void
|
|
17
|
+
mutate(next: T | ((current: T | null) => T)): void
|
|
18
|
+
optimistic<Result>(
|
|
19
|
+
next: T | ((current: T | null) => T),
|
|
20
|
+
action: () => Result | PromiseLike<Result>
|
|
21
|
+
): Promise<Result>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ResourceOptions<T> {
|
|
25
|
+
key?: ResourceKeySource
|
|
26
|
+
fetcher: ResourceFetcher<T>
|
|
27
|
+
staleTime?: number
|
|
28
|
+
cache?: boolean
|
|
29
|
+
strategy?: ResourceCacheStrategy
|
|
30
|
+
retry?: number
|
|
31
|
+
retryDelay?: RetryDelay
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type RetryDelay = number | ((attempt: number, error: Error) => number)
|
|
35
|
+
|
|
36
|
+
export interface ResourceSnapshot<T> {
|
|
37
|
+
readonly data: T | null
|
|
38
|
+
readonly error: Error | null
|
|
39
|
+
readonly loading: boolean
|
|
40
|
+
readonly updatedAt: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ResourceDehydratedEntry {
|
|
44
|
+
readonly key: ResourceKey
|
|
45
|
+
readonly data: unknown
|
|
46
|
+
readonly updatedAt: number
|
|
47
|
+
readonly staleTime: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ResourceDehydratedState {
|
|
51
|
+
readonly version: 1
|
|
52
|
+
readonly entries: readonly ResourceDehydratedEntry[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ResourceClientOptions {
|
|
56
|
+
staleTime?: number
|
|
57
|
+
retry?: number
|
|
58
|
+
retryDelay?: RetryDelay
|
|
59
|
+
onError?: (error: Error, key: ResourceKey | undefined) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ResourceClient {
|
|
63
|
+
resource<T>(fetcher: ResourceFetcher<T>): Resource<T>
|
|
64
|
+
resource<T>(options: ResourceOptions<T>): Resource<T>
|
|
65
|
+
invalidate(key: ResourceKey): void
|
|
66
|
+
prefetchAll(): Promise<void>
|
|
67
|
+
dehydrate(): ResourceDehydratedState
|
|
68
|
+
hydrate(snapshot: unknown): void
|
|
69
|
+
get<T>(key: ResourceKey): ResourceSnapshot<T> | undefined
|
|
70
|
+
clear(): void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ResourceEntry<T> {
|
|
74
|
+
readonly key: ResourceKey | undefined
|
|
75
|
+
readonly data: Signal<T | null>
|
|
76
|
+
readonly error: Signal<Error | null>
|
|
77
|
+
readonly loading: Signal<boolean>
|
|
78
|
+
readonly staleTime: number
|
|
79
|
+
readonly subscribers: Set<object>
|
|
80
|
+
updatedAt: number
|
|
81
|
+
revision: number
|
|
82
|
+
inFlight: Promise<T> | null
|
|
83
|
+
controller: AbortController | null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const defaultClient = createResourceClient()
|
|
87
|
+
|
|
88
|
+
export function resource<T>(fetcher: ResourceFetcher<T>): Resource<T>
|
|
89
|
+
export function resource<T>(options: ResourceOptions<T>): Resource<T>
|
|
90
|
+
export function resource<T>(
|
|
91
|
+
optionsOrFetcher: ResourceOptions<T> | ResourceFetcher<T>
|
|
92
|
+
): Resource<T> {
|
|
93
|
+
return typeof optionsOrFetcher === 'function'
|
|
94
|
+
? defaultClient.resource(optionsOrFetcher)
|
|
95
|
+
: defaultClient.resource(optionsOrFetcher)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function createResourceClient(options: ResourceClientOptions = {}): ResourceClient {
|
|
99
|
+
let owner = createOwner()
|
|
100
|
+
const cache = new Map<string, ResourceEntry<unknown>>()
|
|
101
|
+
const entries = new Set<ResourceEntry<unknown>>()
|
|
102
|
+
const defaultStaleTime = validateStaleTime(options.staleTime ?? 0)
|
|
103
|
+
const defaultRetry = validateRetry(options.retry ?? 0)
|
|
104
|
+
const defaultRetryDelay = options.retryDelay ?? 0
|
|
105
|
+
|
|
106
|
+
function createEntry<T>(key: ResourceKey | undefined, staleTime: number): ResourceEntry<T> {
|
|
107
|
+
const entry: ResourceEntry<T> = owner.run(() => ({
|
|
108
|
+
key,
|
|
109
|
+
data: state<T | null>(null),
|
|
110
|
+
error: state<Error | null>(null),
|
|
111
|
+
loading: state(false),
|
|
112
|
+
staleTime,
|
|
113
|
+
subscribers: new Set(),
|
|
114
|
+
updatedAt: 0,
|
|
115
|
+
revision: 0,
|
|
116
|
+
inFlight: null,
|
|
117
|
+
controller: null
|
|
118
|
+
}))
|
|
119
|
+
owner.onDispose(() => entry.controller?.abort())
|
|
120
|
+
entries.add(entry as ResourceEntry<unknown>)
|
|
121
|
+
return entry
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function execute<T>(
|
|
125
|
+
entry: ResourceEntry<T>,
|
|
126
|
+
fetcher: ResourceFetcher<T>,
|
|
127
|
+
retry: number,
|
|
128
|
+
retryDelay: RetryDelay
|
|
129
|
+
): Promise<T> {
|
|
130
|
+
if (entry.inFlight) return entry.inFlight
|
|
131
|
+
|
|
132
|
+
entry.loading.value = true
|
|
133
|
+
entry.error.value = null
|
|
134
|
+
const controller = new AbortController()
|
|
135
|
+
entry.controller = controller
|
|
136
|
+
const revision = entry.revision
|
|
137
|
+
const request = requestWithRetry(fetcher, controller.signal, retry, retryDelay)
|
|
138
|
+
entry.inFlight = request.then(
|
|
139
|
+
data => {
|
|
140
|
+
// 请求飞行期间 mutate/optimistic 已推进 revision 时,迟到的旧结果不得覆盖新数据。
|
|
141
|
+
if (entry.revision === revision) {
|
|
142
|
+
entry.revision++
|
|
143
|
+
entry.data.value = data
|
|
144
|
+
entry.error.value = null
|
|
145
|
+
entry.updatedAt = Date.now()
|
|
146
|
+
}
|
|
147
|
+
return data
|
|
148
|
+
},
|
|
149
|
+
reason => {
|
|
150
|
+
const error = toError(reason)
|
|
151
|
+
if (entry.revision === revision) {
|
|
152
|
+
entry.error.value = error
|
|
153
|
+
if (!controller.signal.aborted) options.onError?.(error, entry.key)
|
|
154
|
+
}
|
|
155
|
+
throw error
|
|
156
|
+
}
|
|
157
|
+
).finally(() => {
|
|
158
|
+
entry.loading.value = false
|
|
159
|
+
if (entry.controller === controller) entry.controller = null
|
|
160
|
+
entry.inFlight = null
|
|
161
|
+
})
|
|
162
|
+
return entry.inFlight
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isFresh(entry: ResourceEntry<unknown>): boolean {
|
|
166
|
+
return entry.updatedAt > 0 && Date.now() - entry.updatedAt <= entry.staleTime
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function createResource<T>(
|
|
170
|
+
optionsOrFetcher: ResourceOptions<T> | ResourceFetcher<T>
|
|
171
|
+
): Resource<T> {
|
|
172
|
+
const config = normalizeOptions(optionsOrFetcher, defaultStaleTime, defaultRetry, defaultRetryDelay)
|
|
173
|
+
if (isReactiveKey(config.key)) return createReactiveResource(config)
|
|
174
|
+
const staticKey = resolveKey(config.key)
|
|
175
|
+
const keyId = config.cache && staticKey ? stableSerialize(staticKey) : undefined
|
|
176
|
+
let entry = keyId ? cache.get(keyId) as ResourceEntry<T> | undefined : undefined
|
|
177
|
+
if (!entry) {
|
|
178
|
+
entry = createEntry(staticKey, config.staleTime)
|
|
179
|
+
if (keyId) cache.set(keyId, entry as ResourceEntry<unknown>)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const request = (force: boolean): Promise<T> => {
|
|
183
|
+
if (entry.inFlight) return entry.inFlight
|
|
184
|
+
if (!force && config.strategy === 'stale-while-revalidate' && entry.data.value !== null) {
|
|
185
|
+
if (!isFresh(entry)) void execute(entry, config.fetcher, config.retry, config.retryDelay).catch(() => undefined)
|
|
186
|
+
return Promise.resolve(entry.data.value as T)
|
|
187
|
+
}
|
|
188
|
+
if (!force && isFresh(entry as ResourceEntry<unknown>)) return Promise.resolve(entry.data.value as T)
|
|
189
|
+
return execute(entry, config.fetcher, config.retry, config.retryDelay)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Initial failures are represented by error and must not become unhandled rejections.
|
|
193
|
+
void request(false).catch(() => undefined)
|
|
194
|
+
|
|
195
|
+
const resourceHandle = {}
|
|
196
|
+
entry.subscribers.add(resourceHandle)
|
|
197
|
+
const dispose = (): void => {
|
|
198
|
+
if (!entry?.subscribers.delete(resourceHandle)) return
|
|
199
|
+
if (entry.subscribers.size === 0) entry.controller?.abort()
|
|
200
|
+
}
|
|
201
|
+
getCurrentOwner()?.onDispose(dispose)
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
key: staticKey,
|
|
205
|
+
data: entry.data,
|
|
206
|
+
error: entry.error,
|
|
207
|
+
loading: entry.loading,
|
|
208
|
+
dispose,
|
|
209
|
+
refetch: () => request(true),
|
|
210
|
+
prefetch: () => request(false),
|
|
211
|
+
invalidate: () => { entry.updatedAt = 0 },
|
|
212
|
+
mutate(next) {
|
|
213
|
+
const value = typeof next === 'function'
|
|
214
|
+
? (next as (current: T | null) => T)(entry.data.value)
|
|
215
|
+
: next
|
|
216
|
+
entry.revision++
|
|
217
|
+
entry.data.value = value
|
|
218
|
+
entry.error.value = null
|
|
219
|
+
entry.updatedAt = Date.now()
|
|
220
|
+
},
|
|
221
|
+
optimistic<Result>(
|
|
222
|
+
next: T | ((current: T | null) => T),
|
|
223
|
+
action: () => Result | PromiseLike<Result>
|
|
224
|
+
): Promise<Result> {
|
|
225
|
+
const previous = {
|
|
226
|
+
data: entry.data.value,
|
|
227
|
+
error: entry.error.value,
|
|
228
|
+
updatedAt: entry.updatedAt
|
|
229
|
+
}
|
|
230
|
+
const value = typeof next === 'function'
|
|
231
|
+
? (next as (current: T | null) => T)(entry.data.value)
|
|
232
|
+
: next
|
|
233
|
+
const revision = ++entry.revision
|
|
234
|
+
entry.data.value = value
|
|
235
|
+
entry.error.value = null
|
|
236
|
+
entry.updatedAt = Date.now()
|
|
237
|
+
|
|
238
|
+
let actionResult: Promise<Result>
|
|
239
|
+
try {
|
|
240
|
+
actionResult = Promise.resolve(action())
|
|
241
|
+
} catch (reason) {
|
|
242
|
+
actionResult = Promise.reject(reason)
|
|
243
|
+
}
|
|
244
|
+
return actionResult.catch(reason => {
|
|
245
|
+
const error = toError(reason)
|
|
246
|
+
if (entry.revision === revision) {
|
|
247
|
+
entry.revision++
|
|
248
|
+
entry.data.value = previous.data
|
|
249
|
+
entry.error.value = error
|
|
250
|
+
entry.updatedAt = previous.updatedAt
|
|
251
|
+
options.onError?.(error, entry.key)
|
|
252
|
+
}
|
|
253
|
+
throw error
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function createReactiveResource(
|
|
259
|
+
reactiveConfig: ReturnType<typeof normalizeOptions<T>>
|
|
260
|
+
): Resource<T> {
|
|
261
|
+
const data = state<T | null>(null)
|
|
262
|
+
const error = state<Error | null>(null)
|
|
263
|
+
const loading = state(false)
|
|
264
|
+
const handle = {}
|
|
265
|
+
let activeEntry: ResourceEntry<T> | undefined
|
|
266
|
+
let activeKey: ResourceKey | undefined
|
|
267
|
+
let disposed = false
|
|
268
|
+
|
|
269
|
+
const sync = (): void => {
|
|
270
|
+
if (!activeEntry) return
|
|
271
|
+
data.value = activeEntry.data.value
|
|
272
|
+
error.value = activeEntry.error.value
|
|
273
|
+
loading.value = activeEntry.loading.value
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const switchKey = (nextKey: ResourceKey): void => {
|
|
277
|
+
const keyId = reactiveConfig.cache ? stableSerialize(nextKey) : undefined
|
|
278
|
+
let nextEntry = keyId ? cache.get(keyId) as ResourceEntry<T> | undefined : undefined
|
|
279
|
+
if (!nextEntry) {
|
|
280
|
+
nextEntry = createEntry(nextKey, reactiveConfig.staleTime)
|
|
281
|
+
if (keyId) cache.set(keyId, nextEntry as ResourceEntry<unknown>)
|
|
282
|
+
}
|
|
283
|
+
if (activeEntry === nextEntry) {
|
|
284
|
+
sync()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
if (activeEntry) {
|
|
288
|
+
activeEntry.subscribers.delete(handle)
|
|
289
|
+
if (activeEntry.subscribers.size === 0) activeEntry.controller?.abort()
|
|
290
|
+
}
|
|
291
|
+
activeEntry = nextEntry
|
|
292
|
+
activeKey = nextKey
|
|
293
|
+
activeEntry.subscribers.add(handle)
|
|
294
|
+
sync()
|
|
295
|
+
void request(false).catch(() => undefined)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const stop = effect(() => {
|
|
299
|
+
if (disposed) return
|
|
300
|
+
const nextKey = resolveKey(reactiveConfig.key)
|
|
301
|
+
if (!nextKey) throw new Error('resource: 响应式 key 不能是 undefined')
|
|
302
|
+
switchKey(nextKey)
|
|
303
|
+
sync()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const dispose = (): void => {
|
|
307
|
+
if (disposed) return
|
|
308
|
+
disposed = true
|
|
309
|
+
stop.dispose()
|
|
310
|
+
if (activeEntry) {
|
|
311
|
+
activeEntry.subscribers.delete(handle)
|
|
312
|
+
if (activeEntry.subscribers.size === 0) activeEntry.controller?.abort()
|
|
313
|
+
}
|
|
314
|
+
activeEntry = undefined
|
|
315
|
+
}
|
|
316
|
+
getCurrentOwner()?.onDispose(dispose)
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
get key(): ResourceKey | undefined { return activeKey },
|
|
320
|
+
data,
|
|
321
|
+
error,
|
|
322
|
+
loading,
|
|
323
|
+
dispose,
|
|
324
|
+
refetch: () => request(true),
|
|
325
|
+
prefetch: () => request(false),
|
|
326
|
+
invalidate: () => { if (activeEntry) activeEntry.updatedAt = 0 },
|
|
327
|
+
mutate(next) {
|
|
328
|
+
if (!activeEntry) return
|
|
329
|
+
const value = typeof next === 'function'
|
|
330
|
+
? (next as (current: T | null) => T)(activeEntry.data.value)
|
|
331
|
+
: next
|
|
332
|
+
activeEntry.revision++
|
|
333
|
+
activeEntry.data.value = value
|
|
334
|
+
activeEntry.error.value = null
|
|
335
|
+
activeEntry.updatedAt = Date.now()
|
|
336
|
+
},
|
|
337
|
+
optimistic<Result>(
|
|
338
|
+
next: T | ((current: T | null) => T),
|
|
339
|
+
action: () => Result | PromiseLike<Result>
|
|
340
|
+
): Promise<Result> {
|
|
341
|
+
if (!activeEntry) return Promise.reject(new Error('resource: key 尚未初始化'))
|
|
342
|
+
return createOptimistic<Result>(activeEntry, next, action)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function request(force: boolean): Promise<T> {
|
|
347
|
+
if (!activeEntry) return Promise.reject(new Error('resource: key 尚未初始化'))
|
|
348
|
+
if (!force && isFresh(activeEntry) && reactiveConfig.strategy === 'cache-first') {
|
|
349
|
+
return Promise.resolve(activeEntry.data.value as T)
|
|
350
|
+
}
|
|
351
|
+
if (!force && reactiveConfig.strategy === 'stale-while-revalidate'
|
|
352
|
+
&& activeEntry.data.value !== null) {
|
|
353
|
+
if (!isFresh(activeEntry)) void execute(activeEntry, reactiveConfig.fetcher, reactiveConfig.retry, reactiveConfig.retryDelay)
|
|
354
|
+
.catch(() => undefined)
|
|
355
|
+
return Promise.resolve(activeEntry.data.value as T)
|
|
356
|
+
}
|
|
357
|
+
return execute(activeEntry, reactiveConfig.fetcher, reactiveConfig.retry, reactiveConfig.retryDelay)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function createOptimistic<Result>(
|
|
362
|
+
target: ResourceEntry<T>,
|
|
363
|
+
next: T | ((current: T | null) => T),
|
|
364
|
+
action: () => Result | PromiseLike<Result>
|
|
365
|
+
): Promise<Result> {
|
|
366
|
+
const previous = { data: target.data.value, error: target.error.value, updatedAt: target.updatedAt }
|
|
367
|
+
const value = typeof next === 'function'
|
|
368
|
+
? (next as (current: T | null) => T)(target.data.value)
|
|
369
|
+
: next
|
|
370
|
+
const revision = ++target.revision
|
|
371
|
+
target.data.value = value
|
|
372
|
+
target.error.value = null
|
|
373
|
+
target.updatedAt = Date.now()
|
|
374
|
+
return Promise.resolve().then(action).catch(reason => {
|
|
375
|
+
const failure = toError(reason)
|
|
376
|
+
if (target.revision === revision) {
|
|
377
|
+
target.revision++
|
|
378
|
+
target.data.value = previous.data
|
|
379
|
+
target.error.value = failure
|
|
380
|
+
target.updatedAt = previous.updatedAt
|
|
381
|
+
options.onError?.(failure, target.key)
|
|
382
|
+
}
|
|
383
|
+
throw failure
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return {
|
|
389
|
+
resource: createResource,
|
|
390
|
+
invalidate(key) {
|
|
391
|
+
const entry = cache.get(stableSerialize(key))
|
|
392
|
+
if (entry) entry.updatedAt = 0
|
|
393
|
+
},
|
|
394
|
+
async prefetchAll() {
|
|
395
|
+
const requests = [...entries]
|
|
396
|
+
.map(entry => entry.inFlight)
|
|
397
|
+
.filter((request): request is Promise<unknown> => request !== null)
|
|
398
|
+
await Promise.allSettled(requests)
|
|
399
|
+
},
|
|
400
|
+
dehydrate() {
|
|
401
|
+
const entries: ResourceDehydratedEntry[] = []
|
|
402
|
+
for (const entry of cache.values()) {
|
|
403
|
+
if (entry.updatedAt <= 0 || entry.error.value) continue
|
|
404
|
+
entries.push({
|
|
405
|
+
key: entry.key ?? [],
|
|
406
|
+
data: entry.data.value,
|
|
407
|
+
updatedAt: entry.updatedAt,
|
|
408
|
+
staleTime: entry.staleTime
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
return { version: 1, entries }
|
|
412
|
+
},
|
|
413
|
+
hydrate(snapshot) {
|
|
414
|
+
for (const restored of parseDehydratedState(snapshot).entries) {
|
|
415
|
+
const keyId = stableSerialize(restored.key)
|
|
416
|
+
const entry = cache.get(keyId) as ResourceEntry<unknown> | undefined
|
|
417
|
+
?? createEntry(restored.key, restored.staleTime)
|
|
418
|
+
entry.data.value = restored.data
|
|
419
|
+
entry.error.value = null
|
|
420
|
+
entry.loading.value = false
|
|
421
|
+
entry.updatedAt = restored.updatedAt
|
|
422
|
+
entry.revision++
|
|
423
|
+
cache.set(keyId, entry)
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
get<T>(key: ResourceKey): ResourceSnapshot<T> | undefined {
|
|
427
|
+
const entry = cache.get(stableSerialize(key)) as ResourceEntry<T> | undefined
|
|
428
|
+
if (!entry) return undefined
|
|
429
|
+
return {
|
|
430
|
+
data: entry.data.value,
|
|
431
|
+
error: entry.error.value,
|
|
432
|
+
loading: entry.loading.value,
|
|
433
|
+
updatedAt: entry.updatedAt
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
clear() {
|
|
437
|
+
cache.clear()
|
|
438
|
+
entries.clear()
|
|
439
|
+
owner.dispose()
|
|
440
|
+
owner = createOwner()
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function normalizeOptions<T>(
|
|
446
|
+
optionsOrFetcher: ResourceOptions<T> | ResourceFetcher<T>,
|
|
447
|
+
defaultStaleTime: number,
|
|
448
|
+
defaultRetry: number,
|
|
449
|
+
defaultRetryDelay: RetryDelay
|
|
450
|
+
): Required<Pick<ResourceOptions<T>, 'fetcher' | 'staleTime' | 'cache' | 'strategy' | 'retry' | 'retryDelay'>> & Pick<ResourceOptions<T>, 'key'> {
|
|
451
|
+
if (typeof optionsOrFetcher === 'function') {
|
|
452
|
+
return {
|
|
453
|
+
fetcher: optionsOrFetcher,
|
|
454
|
+
staleTime: defaultStaleTime,
|
|
455
|
+
cache: false,
|
|
456
|
+
strategy: 'cache-first',
|
|
457
|
+
retry: defaultRetry,
|
|
458
|
+
retryDelay: defaultRetryDelay
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
key: optionsOrFetcher.key,
|
|
463
|
+
fetcher: optionsOrFetcher.fetcher,
|
|
464
|
+
staleTime: validateStaleTime(optionsOrFetcher.staleTime ?? defaultStaleTime),
|
|
465
|
+
cache: optionsOrFetcher.cache ?? Boolean(optionsOrFetcher.key),
|
|
466
|
+
strategy: optionsOrFetcher.strategy ?? 'cache-first',
|
|
467
|
+
retry: validateRetry(optionsOrFetcher.retry ?? defaultRetry),
|
|
468
|
+
retryDelay: optionsOrFetcher.retryDelay ?? defaultRetryDelay
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function isReactiveKey(key: ResourceKeySource | undefined): key is Signal<ResourceKey> | (() => ResourceKey) {
|
|
473
|
+
return typeof key === 'function' || isSignal(key)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function isSignal(value: unknown): value is Signal<ResourceKey> {
|
|
477
|
+
return value !== null && typeof value === 'object' && 'value' in value
|
|
478
|
+
&& typeof (value as { dispose?: unknown }).dispose === 'function'
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function resolveKey(source: ResourceKeySource | undefined): ResourceKey | undefined {
|
|
482
|
+
const key = typeof source === 'function'
|
|
483
|
+
? source()
|
|
484
|
+
: isSignal(source) ? source.value : source
|
|
485
|
+
if (key === undefined) return undefined
|
|
486
|
+
if (!Array.isArray(key)) throw new Error('resource: key 必须是数组')
|
|
487
|
+
return key
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function requestWithRetry<T>(
|
|
491
|
+
fetcher: ResourceFetcher<T>,
|
|
492
|
+
signal: AbortSignal,
|
|
493
|
+
retry: number,
|
|
494
|
+
retryDelay: RetryDelay
|
|
495
|
+
): Promise<T> {
|
|
496
|
+
let attempt = 0
|
|
497
|
+
const request = (): Promise<T> => Promise.resolve().then(() => fetcher(signal)).catch(reason => {
|
|
498
|
+
const error = toError(reason)
|
|
499
|
+
if (signal.aborted) throw error
|
|
500
|
+
if (attempt++ >= retry) throw error
|
|
501
|
+
const delay = resolveRetryDelay(retryDelay, attempt, error)
|
|
502
|
+
return delay > 0 ? wait(delay).then(request) : request()
|
|
503
|
+
})
|
|
504
|
+
return request()
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function resolveRetryDelay(retryDelay: RetryDelay, attempt: number, error: Error): number {
|
|
508
|
+
const delay = typeof retryDelay === 'function' ? retryDelay(attempt, error) : retryDelay
|
|
509
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
510
|
+
throw new Error('resource: retryDelay 必须是大于等于 0 的有限数字')
|
|
511
|
+
}
|
|
512
|
+
return delay
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function wait(delay: number): Promise<void> {
|
|
516
|
+
return new Promise(resolve => setTimeout(resolve, delay))
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function validateStaleTime(staleTime: number): number {
|
|
520
|
+
if (!Number.isFinite(staleTime) || staleTime < 0) {
|
|
521
|
+
throw new Error('resource: staleTime 必须是大于等于 0 的有限数字')
|
|
522
|
+
}
|
|
523
|
+
return staleTime
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function validateRetry(retry: number): number {
|
|
527
|
+
if (!Number.isInteger(retry) || retry < 0) {
|
|
528
|
+
throw new Error('resource: retry 必须是大于等于 0 的整数')
|
|
529
|
+
}
|
|
530
|
+
return retry
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function toError(reason: unknown): Error {
|
|
534
|
+
return reason instanceof Error ? reason : new Error(String(reason))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function stableSerialize(value: unknown): string {
|
|
538
|
+
return serialize(value, new Set<object>())
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function serializeResourceState(snapshot: ResourceDehydratedState): string {
|
|
542
|
+
const serialized = JSON.stringify(snapshot)
|
|
543
|
+
return serialized
|
|
544
|
+
.replace(/</g, '\\u003c')
|
|
545
|
+
.replace(/>/g, '\\u003e')
|
|
546
|
+
.replace(/&/g, '\\u0026')
|
|
547
|
+
.replace(/\u2028/g, '\\u2028')
|
|
548
|
+
.replace(/\u2029/g, '\\u2029')
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function parseDehydratedState(snapshot: unknown): ResourceDehydratedState {
|
|
552
|
+
let value: unknown = snapshot
|
|
553
|
+
if (typeof snapshot === 'string') {
|
|
554
|
+
try {
|
|
555
|
+
value = JSON.parse(snapshot)
|
|
556
|
+
} catch {
|
|
557
|
+
throw new Error('resource: 预取状态不是有效 JSON')
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (!value || typeof value !== 'object') throw new Error('resource: 预取状态格式无效')
|
|
561
|
+
const candidate = value as { version?: unknown; entries?: unknown }
|
|
562
|
+
if (candidate.version !== 1 || !Array.isArray(candidate.entries)) {
|
|
563
|
+
throw new Error('resource: 预取状态版本或 entries 无效')
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const entries: ResourceDehydratedEntry[] = []
|
|
567
|
+
for (const entry of candidate.entries) {
|
|
568
|
+
if (!entry || typeof entry !== 'object') throw new Error('resource: 预取条目格式无效')
|
|
569
|
+
const candidateEntry = entry as Partial<ResourceDehydratedEntry>
|
|
570
|
+
if (!Array.isArray(candidateEntry.key)
|
|
571
|
+
|| typeof candidateEntry.updatedAt !== 'number'
|
|
572
|
+
|| !Number.isFinite(candidateEntry.updatedAt)
|
|
573
|
+
|| typeof candidateEntry.staleTime !== 'number'
|
|
574
|
+
|| !Number.isFinite(candidateEntry.staleTime)
|
|
575
|
+
|| candidateEntry.staleTime < 0) {
|
|
576
|
+
throw new Error('resource: 预取条目字段无效')
|
|
577
|
+
}
|
|
578
|
+
// Re-serialize now so untrusted keys cannot poison the cache map.
|
|
579
|
+
stableSerialize(candidateEntry.key)
|
|
580
|
+
entries.push({
|
|
581
|
+
key: candidateEntry.key,
|
|
582
|
+
data: candidateEntry.data,
|
|
583
|
+
updatedAt: candidateEntry.updatedAt,
|
|
584
|
+
staleTime: candidateEntry.staleTime
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
return { version: 1, entries }
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function serialize(value: unknown, stack: Set<object>): string {
|
|
591
|
+
if (value === null) return 'null'
|
|
592
|
+
switch (typeof value) {
|
|
593
|
+
case 'string': return `string:${JSON.stringify(value)}`
|
|
594
|
+
case 'boolean': return `boolean:${value}`
|
|
595
|
+
case 'number':
|
|
596
|
+
if (Number.isNaN(value)) return 'number:NaN'
|
|
597
|
+
if (Object.is(value, -0)) return 'number:-0'
|
|
598
|
+
return `number:${value}`
|
|
599
|
+
case 'bigint': return `bigint:${value}`
|
|
600
|
+
case 'undefined': return 'undefined'
|
|
601
|
+
case 'function':
|
|
602
|
+
case 'symbol':
|
|
603
|
+
throw new Error(`resource: key 不能包含 ${typeof value}`)
|
|
604
|
+
case 'object': break
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const object = value as object
|
|
608
|
+
if (stack.has(object)) throw new Error('resource: key 不能包含循环引用')
|
|
609
|
+
stack.add(object)
|
|
610
|
+
try {
|
|
611
|
+
if (Array.isArray(object)) {
|
|
612
|
+
return `array:[${object.map(item => serialize(item, stack)).join(',')}]`
|
|
613
|
+
}
|
|
614
|
+
if (object instanceof Date) return `date:${object.toJSON()}`
|
|
615
|
+
if (object instanceof RegExp) return `regexp:${object.toString()}`
|
|
616
|
+
const entries = Object.keys(object).sort().map(key => `${JSON.stringify(key)}:${serialize((object as Record<string, unknown>)[key], stack)}`)
|
|
617
|
+
return `object:{${entries.join(',')}}`
|
|
618
|
+
} finally {
|
|
619
|
+
stack.delete(object)
|
|
620
|
+
}
|
|
621
|
+
}
|