@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.
@@ -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
+ }