@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/LICENSE
CHANGED
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# @vobs/resource
|
|
2
|
+
|
|
3
|
+
Signal-backed async data for vobs: keyed request caching, stale-while-revalidate, revision-guarded mutations, and SSR dehydration.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/resource
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createResourceClient } from '@vobs/resource'
|
|
15
|
+
|
|
16
|
+
const client = createResourceClient({ staleTime: 30_000, retry: 2 })
|
|
17
|
+
|
|
18
|
+
const users = client.resource({
|
|
19
|
+
key: ['users', { page: 1 }],
|
|
20
|
+
fetcher: signal => fetch('/api/users?page=1', { signal }).then(res => res.json()),
|
|
21
|
+
strategy: 'stale-while-revalidate'
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
users.loading.value // true while the first request is in flight
|
|
25
|
+
users.data.value // T | null once settled
|
|
26
|
+
users.error.value // Error | null on failure
|
|
27
|
+
|
|
28
|
+
await users.refetch()
|
|
29
|
+
client.invalidate(['users', { page: 1 }])
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Resources created with the same serialized key share one cache entry and one in-flight request. Results carry a revision: a response that settles after `mutate` or `optimistic` is discarded, so late data never overwrites newer local writes. With `stale-while-revalidate`, cached data is returned immediately while a fresh request runs in the background; `cache-first` re-fetches only after `staleTime` expires.
|
|
33
|
+
|
|
34
|
+
## API
|
|
35
|
+
|
|
36
|
+
| Signature | Description |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `createResourceClient(options?: ResourceClientOptions): ResourceClient` | Create a client; options set default `staleTime`, `retry`, `retryDelay`, and `onError`. |
|
|
39
|
+
| `resource(fetcher or options): Resource` | Create a resource on the module-level default client. |
|
|
40
|
+
| `client.resource(options): Resource` | Pass `key` to enable caching (also accepts a signal or function key for reactive re-fetch); `cache: false` opts out. |
|
|
41
|
+
| `resource.data / error / loading` | Signals for current value, error, and request state. |
|
|
42
|
+
| `resource.refetch() / prefetch() / invalidate()` | Force a request, start one opportunistically, or expire the cache. |
|
|
43
|
+
| `resource.execute()` | Runs the fetcher now and returns the result; revision-guarded like every settle path. |
|
|
44
|
+
| `resource.mutate(next)` | Write through the shared cache immediately. |
|
|
45
|
+
| `resource.optimistic(next, action)` | Apply `next`, then roll back and set `error` if `action` rejects. |
|
|
46
|
+
| `resource.dispose()` | Detach; aborts the request when it is the last subscriber. |
|
|
47
|
+
| `client.invalidate(key) / client.get(key)` | Expire or read a cached snapshot by key. |
|
|
48
|
+
| `client.prefetchAll() / dehydrate() / hydrate(snapshot) / clear()` | Await in-flight work, serialize for SSR, restore, and reset. |
|
|
49
|
+
| `stableSerialize(value): string` | Deterministic serialization used for cache keys. |
|
|
50
|
+
| `serializeResourceState(snapshot): string` | JSON-stringify dehydrated state with HTML-safe escaping. |
|
|
51
|
+
| `resourcePlugin(options?) / resourceRouterPlugin(options?)` | Provide the client as `RESOURCE_KEY`; run `route.meta.prefetch` handlers before navigation. |
|
|
52
|
+
| `insertResourceBoundary(parent, anchor, options) / ResourceBoundary(props)` | Branch on loading, empty, error, and data of a resource. |
|
|
53
|
+
|
|
54
|
+
## Types
|
|
55
|
+
|
|
56
|
+
Resource, ResourceKey, ResourceKeySource, ResourceFetcher, ResourceOptions, ResourceCacheStrategy, RetryDelay, ResourceSnapshot, ResourceClient, ResourceClientOptions, ResourceDehydratedEntry, ResourceDehydratedState, ResourceBoundaryChild, ResourceBoundaryFallback, ResourceBoundaryOptions, ResourceBoundaryProps, ResourceBoundaryView, ResourcePluginOptions, ResourceRoutePrefetch, ResourceRoutePrefetchContext, ResourceRouterPluginOptions
|
package/package.json
CHANGED
|
@@ -1,41 +1,22 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@vobs/resource",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"description": "Declarative async data resources for vobs: fetch, cancel, cache, invalidate, refetch and mutation.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"publishConfig": {
|
|
7
|
-
"access": "public"
|
|
8
|
-
},
|
|
9
2
|
"license": "MIT",
|
|
10
|
-
"author": "vobsjs",
|
|
11
|
-
"repository": {
|
|
12
|
-
"type": "git",
|
|
13
|
-
"url": "git+https://github.com/vobsjs/vobs.git",
|
|
14
|
-
"directory": "packages/features/resource"
|
|
15
|
-
},
|
|
16
|
-
"bugs": {
|
|
17
|
-
"url": "https://github.com/vobsjs/vobs/issues"
|
|
18
|
-
},
|
|
19
|
-
"homepage": "https://github.com/vobsjs/vobs#readme",
|
|
20
|
-
"dependencies": {
|
|
21
|
-
"@vobs/reactivity": "0.1.0",
|
|
22
|
-
"@vobs/runtime-core": "0.1.0"
|
|
23
|
-
},
|
|
24
3
|
"files": [
|
|
25
|
-
"
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
26
7
|
],
|
|
8
|
+
"name": "@vobs/resource",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "src/index.ts",
|
|
12
|
+
"types": "src/index.ts",
|
|
27
13
|
"exports": {
|
|
28
|
-
".":
|
|
29
|
-
"types": "./dist/index.d.ts",
|
|
30
|
-
"import": "./dist/index.js"
|
|
31
|
-
},
|
|
32
|
-
"./package.json": "./package.json"
|
|
14
|
+
".": "./src/index.ts"
|
|
33
15
|
},
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"node": ">=20.19.0"
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@vobs/reactivity": "1.0.0",
|
|
18
|
+
"@vobs/router": "1.0.0",
|
|
19
|
+
"@vobs/runtime": "1.0.0",
|
|
20
|
+
"@vobs/vobs": "1.0.0"
|
|
40
21
|
}
|
|
41
22
|
}
|
package/src/boundary.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createFragment, insertBoundary, type NodeFactory, type VobsNode } from '@vobs/vobs'
|
|
2
|
+
import type { Resource } from './resource'
|
|
3
|
+
|
|
4
|
+
export type ResourceBoundaryChild<T> = (data: T) => ReturnType<NodeFactory>
|
|
5
|
+
export type ResourceBoundaryView = ReturnType<NodeFactory> | NodeFactory
|
|
6
|
+
export type ResourceBoundaryFallback = (
|
|
7
|
+
error: Error,
|
|
8
|
+
retry: () => Promise<unknown>
|
|
9
|
+
) => ReturnType<NodeFactory>
|
|
10
|
+
|
|
11
|
+
export interface ResourceBoundaryOptions<T> {
|
|
12
|
+
resource: Resource<T>
|
|
13
|
+
children: ResourceBoundaryChild<T>
|
|
14
|
+
loading?: ResourceBoundaryView
|
|
15
|
+
empty?: ResourceBoundaryView
|
|
16
|
+
fallback?: ResourceBoundaryFallback
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ResourceBoundaryProps<T> extends ResourceBoundaryOptions<T> {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Inserts a reactive Resource state branch. The compiler can lower a future
|
|
23
|
+
* ResourceBoundary JSX element to this host-level instruction without a wrapper node.
|
|
24
|
+
*/
|
|
25
|
+
export function insertResourceBoundary<T>(
|
|
26
|
+
parent: Node,
|
|
27
|
+
anchor: Node | null,
|
|
28
|
+
options: ResourceBoundaryOptions<T>
|
|
29
|
+
): void {
|
|
30
|
+
insertBoundary(parent, anchor, {
|
|
31
|
+
onRetry: () => options.resource.refetch(),
|
|
32
|
+
fallback: (error, retry) => options.fallback?.(error, () => Promise.resolve(retry())) ?? null,
|
|
33
|
+
children: () => {
|
|
34
|
+
if (options.resource.error.value) throw options.resource.error.value
|
|
35
|
+
if (options.resource.loading.value) return resolveView(options.loading)
|
|
36
|
+
|
|
37
|
+
const data = options.resource.data.value
|
|
38
|
+
if (data === null) return resolveView(options.empty)
|
|
39
|
+
return options.children(data)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Component-shaped API backed by the same no-wrapper boundary instruction. */
|
|
45
|
+
export function ResourceBoundary<T>(props: ResourceBoundaryProps<T>): VobsNode {
|
|
46
|
+
return createFragment((parent, anchor) => insertResourceBoundary(parent, anchor, props))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveView(view: ResourceBoundaryView | undefined): ReturnType<NodeFactory> {
|
|
50
|
+
if (!view) return null
|
|
51
|
+
return typeof view === 'function' ? view() : view
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createResourceClient,
|
|
3
|
+
resource,
|
|
4
|
+
stableSerialize,
|
|
5
|
+
serializeResourceState
|
|
6
|
+
} from './resource'
|
|
7
|
+
export { insertResourceBoundary, ResourceBoundary } from './boundary'
|
|
8
|
+
export type {
|
|
9
|
+
Resource,
|
|
10
|
+
ResourceCacheStrategy,
|
|
11
|
+
ResourceClient,
|
|
12
|
+
ResourceClientOptions,
|
|
13
|
+
ResourceDehydratedEntry,
|
|
14
|
+
ResourceDehydratedState,
|
|
15
|
+
ResourceFetcher,
|
|
16
|
+
ResourceKey,
|
|
17
|
+
ResourceKeySource,
|
|
18
|
+
ResourceOptions,
|
|
19
|
+
RetryDelay,
|
|
20
|
+
ResourceSnapshot
|
|
21
|
+
} from './resource'
|
|
22
|
+
export type {
|
|
23
|
+
ResourceBoundaryChild,
|
|
24
|
+
ResourceBoundaryFallback,
|
|
25
|
+
ResourceBoundaryOptions,
|
|
26
|
+
ResourceBoundaryProps,
|
|
27
|
+
ResourceBoundaryView
|
|
28
|
+
} from './boundary'
|
|
29
|
+
export { RESOURCE_KEY, resourcePlugin, resourceRouterPlugin } from './plugin'
|
|
30
|
+
export type {
|
|
31
|
+
ResourcePluginOptions,
|
|
32
|
+
ResourceRoutePrefetchContext,
|
|
33
|
+
ResourceRoutePrefetch,
|
|
34
|
+
ResourceRouterPluginOptions
|
|
35
|
+
} from './plugin'
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createInjectionKey, type VobsPlugin } from '@vobs/vobs'
|
|
2
|
+
import { ROUTER_KEY, type RouteLocation, type Router } from '@vobs/router'
|
|
3
|
+
import { createResourceClient, type ResourceClient, type ResourceClientOptions } from './resource'
|
|
4
|
+
|
|
5
|
+
export const RESOURCE_KEY = createInjectionKey<ResourceClient>('vobs.resource')
|
|
6
|
+
|
|
7
|
+
export interface ResourcePluginOptions extends ResourceClientOptions {
|
|
8
|
+
client?: ResourceClient
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ResourceRoutePrefetchContext {
|
|
12
|
+
readonly route: RouteLocation
|
|
13
|
+
readonly client: ResourceClient
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ResourceRoutePrefetch = (
|
|
17
|
+
context: ResourceRoutePrefetchContext
|
|
18
|
+
) => unknown | PromiseLike<unknown>
|
|
19
|
+
|
|
20
|
+
export interface ResourceRouterPluginOptions {
|
|
21
|
+
readonly router?: Router
|
|
22
|
+
readonly client?: ResourceClient
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function resourcePlugin(options: ResourcePluginOptions = {}): VobsPlugin {
|
|
26
|
+
return {
|
|
27
|
+
name: '@vobs/resource',
|
|
28
|
+
version: '0.1.0',
|
|
29
|
+
install(context) {
|
|
30
|
+
const client = options.client ?? createResourceClient(options)
|
|
31
|
+
context.provide(RESOURCE_KEY, client)
|
|
32
|
+
return () => {
|
|
33
|
+
if (!options.client) client.clear()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resourceRouterPlugin(options: ResourceRouterPluginOptions = {}): VobsPlugin {
|
|
40
|
+
return {
|
|
41
|
+
name: '@vobs/resource-router',
|
|
42
|
+
version: '0.1.0',
|
|
43
|
+
install(context) {
|
|
44
|
+
const router = options.router ?? context.inject(ROUTER_KEY)
|
|
45
|
+
const client = options.client ?? context.inject(RESOURCE_KEY)
|
|
46
|
+
if (!router) throw new Error('Vobs Resource Router: 找不到 Router,请安装 routerPlugin 或传入 router')
|
|
47
|
+
if (!client) throw new Error('Vobs Resource Router: 找不到 ResourceClient,请安装 resourcePlugin 或传入 client')
|
|
48
|
+
|
|
49
|
+
return router.beforeEach(async to => {
|
|
50
|
+
const prefetch = readPrefetch(to)
|
|
51
|
+
await Promise.all(prefetch.map((task, index) => router.devtools.trackDataRequest(
|
|
52
|
+
'loader',
|
|
53
|
+
`${to.fullPath}#prefetch-${index + 1}`,
|
|
54
|
+
() => task({ route: to, client }),
|
|
55
|
+
{ route: to.fullPath, trigger: 'resource' }
|
|
56
|
+
)))
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readPrefetch(route: RouteLocation): readonly ResourceRoutePrefetch[] {
|
|
63
|
+
const value = route.meta.prefetch
|
|
64
|
+
if (value === undefined) return []
|
|
65
|
+
if (typeof value === 'function') return [value as ResourceRoutePrefetch]
|
|
66
|
+
if (!Array.isArray(value) || value.some(task => typeof task !== 'function')) {
|
|
67
|
+
throw new Error('Vobs Resource Router: route.meta.prefetch 必须是函数或函数数组')
|
|
68
|
+
}
|
|
69
|
+
return value as ResourceRoutePrefetch[]
|
|
70
|
+
}
|