@vobs/resource 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/LICENSE +21 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.js +308 -0
- package/dist/loader.d.ts +32 -0
- package/dist/loader.js +93 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vobsjs
|
|
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/dist/index.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** @license MIT
|
|
2
|
+
* Copyright (c) 2026 vobsjs
|
|
3
|
+
* @vobs/resource
|
|
4
|
+
*/
|
|
5
|
+
import { type ReadonlySignal } from '@vobs/reactivity';
|
|
6
|
+
import { type Owner } from '@vobs/runtime-core';
|
|
7
|
+
/**
|
|
8
|
+
* Implementation of the Resource And Action contract (docs/contracts/resource-action.md).
|
|
9
|
+
*
|
|
10
|
+
* Declarative async data resources: three-state machine (loading/error/data), key-based caching with
|
|
11
|
+
* concurrent deduplication, AbortSignal cancellation + race protection, invalidate/refetch, optimistic mutation.
|
|
12
|
+
*/
|
|
13
|
+
export interface ResourceSourceContext {
|
|
14
|
+
readonly signal: AbortSignal;
|
|
15
|
+
readonly key: string;
|
|
16
|
+
}
|
|
17
|
+
export type ResourceSource<T> = (context: ResourceSourceContext) => Promise<T> | T;
|
|
18
|
+
/** Cache entry (the unit stored by ResourceCache): wraps data + state (contract §2). */
|
|
19
|
+
export interface CacheEntry<T> {
|
|
20
|
+
readonly data: T;
|
|
21
|
+
}
|
|
22
|
+
export interface ResourceCache<T> {
|
|
23
|
+
get(key: string): Promise<CacheEntry<T>> | CacheEntry<T> | undefined;
|
|
24
|
+
set(key: string, entry: CacheEntry<T>): void;
|
|
25
|
+
delete(key: string): void;
|
|
26
|
+
clear(): void;
|
|
27
|
+
}
|
|
28
|
+
export interface ResourceOptions<T> {
|
|
29
|
+
/** Owner the resource belongs to (default currentOwner); in-flight work is cancelled and released on Owner disposal. */
|
|
30
|
+
readonly owner?: Owner;
|
|
31
|
+
/** Cache key; defaults to an independent counter per resource instance. */
|
|
32
|
+
readonly key?: string;
|
|
33
|
+
/** Custom cache (defaults to an in-process Map). */
|
|
34
|
+
readonly cache?: ResourceCache<T>;
|
|
35
|
+
}
|
|
36
|
+
export interface Resource<T> {
|
|
37
|
+
readonly key: string;
|
|
38
|
+
readonly data: ReadonlySignal<T | undefined>;
|
|
39
|
+
readonly error: ReadonlySignal<unknown>;
|
|
40
|
+
readonly loading: ReadonlySignal<boolean>;
|
|
41
|
+
/** Force a refetch (ignores the cache). */
|
|
42
|
+
refetch(): Promise<void>;
|
|
43
|
+
/** Invalidates the cached entry and refetches. */
|
|
44
|
+
invalidate(): Promise<void>;
|
|
45
|
+
/** Optimistically updates local data; rolls back and sets error if the updater throws. */
|
|
46
|
+
mutate(updater: (current: T | undefined) => T | Promise<T>): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Resolves on first fetch settle (success/failure/cancel/sync hit/disposal) — SSR async setup
|
|
49
|
+
* awaits resource readiness before serializing (resource-action §8 assembly pattern). Idempotent.
|
|
50
|
+
*/
|
|
51
|
+
whenReady(): Promise<void>;
|
|
52
|
+
/** Releases: cancels in-flight work and cleans up (idempotent). */
|
|
53
|
+
dispose(): void;
|
|
54
|
+
}
|
|
55
|
+
export declare function createResource<T>(source: ResourceSource<T>, options?: ResourceOptions<T>): Resource<T>;
|
|
56
|
+
export { createResourceLoader, isResourceAbortError, type ResourceLoader, type ResourceLoaderContext, type ResourceLoaderOptions, } from './loader.js';
|
|
57
|
+
/**
|
|
58
|
+
* Server-state serialization helper (resource-action.md §8 + ADR-0002):
|
|
59
|
+
*
|
|
60
|
+
* On the SSR side, ready Resource data is extracted into a JSON-serializable snapshot (injected with the page
|
|
61
|
+
* initial state); before client hydration, the snapshot pre-fills the cache so createResource for the same key
|
|
62
|
+
* hits the cache without re-requesting (symmetric rebuild, identical first screen).
|
|
63
|
+
*
|
|
64
|
+
* Assembly lives in the host/app layer: server-renderer does not depend on the resource package (dependency direction);
|
|
65
|
+
* SSR serializes → injects initial state → client createHydratedCache + createResource.
|
|
66
|
+
*/
|
|
67
|
+
/** Serializable snapshot of a single Resource (data only, without error/loading transients, see contract §8). */
|
|
68
|
+
export interface ResourceStateSnapshot<T = unknown> {
|
|
69
|
+
readonly key: string;
|
|
70
|
+
readonly data: T;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Extracts a single Resource snapshot; returns undefined when data is not ready (undefined) or
|
|
74
|
+
* the default key (`resource:N`) is used (unstable across processes, SSR serialization must use explicit keys, see contract §8).
|
|
75
|
+
*/
|
|
76
|
+
export declare function serializeResourceState<T>(resource: Resource<T>): ResourceStateSnapshot<T> | undefined;
|
|
77
|
+
export interface SerializeResourceStatesOptions {
|
|
78
|
+
/** Callback invoked when a default key (`resource:N`) is skipped — prevents silent mismatches (contract §8). */
|
|
79
|
+
readonly onWarning?: (key: string) => void;
|
|
80
|
+
}
|
|
81
|
+
/** Batch extraction; skips unready resources and default-key resources (the latter are reported via onWarning). */
|
|
82
|
+
export declare function serializeResourceStates<T>(resources: readonly Resource<T>[], options?: SerializeResourceStatesOptions): ResourceStateSnapshot<T>[];
|
|
83
|
+
/**
|
|
84
|
+
* Client hydration: builds a pre-filled cache from snapshots (a get hit returns immediately without calling source).
|
|
85
|
+
* Consistent with createResource's cache contract (ResourceCache stores CacheEntry items).
|
|
86
|
+
*/
|
|
87
|
+
export declare function createHydratedCache<T>(snapshots: readonly ResourceStateSnapshot<T>[]): ResourceCache<T>;
|
|
88
|
+
/**
|
|
89
|
+
* Action (business mutation, resource-action.md §5 + ADR-0002):
|
|
90
|
+
*
|
|
91
|
+
* A stateful write primitive separated from Resource's read responsibility. Three-state signals
|
|
92
|
+
* (pending/error/data), AbortSignal cancellation + race protection, Owner ownership; on success it can
|
|
93
|
+
* invalidate declared associated Resource cache keys (read-write linkage loop).
|
|
94
|
+
*
|
|
95
|
+
* Difference from `mutate`: mutate is a resource-level optimistic update (mutates local data, no pending
|
|
96
|
+
* semantics, no invalidation linkage); Action is a business-level write (form submit/change API) that refreshes the read side on success.
|
|
97
|
+
*/
|
|
98
|
+
export interface ActionSourceContext<Args> {
|
|
99
|
+
readonly signal: AbortSignal;
|
|
100
|
+
readonly args: Args;
|
|
101
|
+
}
|
|
102
|
+
export type ActionSource<Args, T> = (context: ActionSourceContext<Args>) => Promise<T> | T;
|
|
103
|
+
export interface ActionOptions<Args, T> {
|
|
104
|
+
/** Owner the Action belongs to (default currentOwner); in-flight work is cancelled on Owner disposal. */
|
|
105
|
+
readonly owner?: Owner;
|
|
106
|
+
/** Resource cache keys invalidated on success (read-write linkage: the read side auto-refreshes after a write). */
|
|
107
|
+
readonly invalidates?: readonly string[] | ((args: Args, data: T) => readonly string[]);
|
|
108
|
+
}
|
|
109
|
+
export interface Action<Args = void, T = unknown> {
|
|
110
|
+
readonly pending: ReadonlySignal<boolean>;
|
|
111
|
+
readonly error: ReadonlySignal<unknown>;
|
|
112
|
+
readonly data: ReadonlySignal<T | undefined>;
|
|
113
|
+
/** Runs one write. Cancellation (AbortError) does not set error; the returned Promise resolves on completion. */
|
|
114
|
+
run(args: Args): Promise<T>;
|
|
115
|
+
/** Cleans up: cancels in-flight work (idempotent). */
|
|
116
|
+
dispose(): void;
|
|
117
|
+
}
|
|
118
|
+
export declare function createAction<Args, T>(source: ActionSource<Args, T>, options?: ActionOptions<Args, T>): Action<Args, T>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/** @license MIT
|
|
2
|
+
* Copyright (c) 2026 vobsjs
|
|
3
|
+
* @vobs/resource
|
|
4
|
+
*/
|
|
5
|
+
import { signal } from '@vobs/reactivity';
|
|
6
|
+
import { currentOwner, createRuntimeError } from '@vobs/runtime-core';
|
|
7
|
+
const defaultCacheStore = new Map();
|
|
8
|
+
// Cross-instance concurrent deduplication per key (contract §2): dedup tables are isolated per cache instance
|
|
9
|
+
// (an SSR per-request cache gets per-request deduplication without cross-contamination; the default cache shares one table, matching historical behavior).
|
|
10
|
+
const cacheInFlightTables = new WeakMap();
|
|
11
|
+
let defaultKeyCounter = 0;
|
|
12
|
+
/** Default key pattern (generated by a process-level counter, unstable across processes — SSR serialization must use explicit keys). */
|
|
13
|
+
const DEFAULT_KEY_PATTERN = /^resource:\d+$/u;
|
|
14
|
+
function inFlightTable(cache) {
|
|
15
|
+
let table = cacheInFlightTables.get(cache);
|
|
16
|
+
if (table === undefined) {
|
|
17
|
+
table = new Map();
|
|
18
|
+
cacheInFlightTables.set(cache, table);
|
|
19
|
+
}
|
|
20
|
+
return table;
|
|
21
|
+
}
|
|
22
|
+
function isPromiseLike(value) {
|
|
23
|
+
return (typeof value === 'object' &&
|
|
24
|
+
value !== null &&
|
|
25
|
+
'then' in value &&
|
|
26
|
+
typeof value.then === 'function');
|
|
27
|
+
}
|
|
28
|
+
function isAbortError(error) {
|
|
29
|
+
return (typeof error === 'object' &&
|
|
30
|
+
error !== null &&
|
|
31
|
+
error.name === 'AbortError');
|
|
32
|
+
}
|
|
33
|
+
export function createResource(source, options = {}) {
|
|
34
|
+
const key = options.key ?? `resource:${defaultKeyCounter++}`;
|
|
35
|
+
const cache = (options.cache ?? defaultCacheStore);
|
|
36
|
+
const inFlight = inFlightTable(cache);
|
|
37
|
+
const owner = options.owner ?? currentOwner();
|
|
38
|
+
const data = signal(undefined);
|
|
39
|
+
const error = signal(undefined);
|
|
40
|
+
const loading = signal(false);
|
|
41
|
+
let controller;
|
|
42
|
+
let fetchToken = 0;
|
|
43
|
+
let disposed = false;
|
|
44
|
+
let readyResolved = false;
|
|
45
|
+
let resolveReady;
|
|
46
|
+
const ready = new Promise((resolve) => {
|
|
47
|
+
resolveReady = resolve;
|
|
48
|
+
});
|
|
49
|
+
const markReady = () => {
|
|
50
|
+
if (readyResolved)
|
|
51
|
+
return;
|
|
52
|
+
readyResolved = true;
|
|
53
|
+
resolveReady?.();
|
|
54
|
+
};
|
|
55
|
+
const applySuccess = (value, token) => {
|
|
56
|
+
if (disposed || token !== fetchToken)
|
|
57
|
+
return;
|
|
58
|
+
cache.set(key, { data: value });
|
|
59
|
+
data.value = value;
|
|
60
|
+
error.value = undefined;
|
|
61
|
+
loading.value = false;
|
|
62
|
+
markReady();
|
|
63
|
+
};
|
|
64
|
+
const applyFailure = (failure, token) => {
|
|
65
|
+
if (disposed || token !== fetchToken)
|
|
66
|
+
return;
|
|
67
|
+
if (isAbortError(failure)) {
|
|
68
|
+
// Cancelled: no error, no retry (contract §5); cancellation also counts as first settle (SSR await does not hang).
|
|
69
|
+
loading.value = false;
|
|
70
|
+
markReady();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
error.value = failure;
|
|
74
|
+
loading.value = false;
|
|
75
|
+
markReady();
|
|
76
|
+
};
|
|
77
|
+
const run = async (token) => {
|
|
78
|
+
controller?.abort();
|
|
79
|
+
controller = new AbortController();
|
|
80
|
+
loading.value = true;
|
|
81
|
+
// Sync hit (hydrated/pre-warmed cache): data is readable on the first frame of template rendering (contract §8 sync readiness).
|
|
82
|
+
const maybeCached = cache.get(key);
|
|
83
|
+
if (maybeCached !== undefined && !isPromiseLike(maybeCached)) {
|
|
84
|
+
if (disposed || token !== fetchToken)
|
|
85
|
+
return;
|
|
86
|
+
data.value = maybeCached.data;
|
|
87
|
+
error.value = undefined;
|
|
88
|
+
loading.value = false;
|
|
89
|
+
markReady();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Miss: a single await yields uniformly (a Promise awaits resolve / a sync value is wrapped in a resolved promise) —
|
|
93
|
+
// if dispose or a new refetch runs during the yield, this run terminates (cancelled runs never call source, contract semantics).
|
|
94
|
+
const cached = await Promise.resolve(maybeCached);
|
|
95
|
+
if (disposed || token !== fetchToken)
|
|
96
|
+
return;
|
|
97
|
+
if (cached !== undefined) {
|
|
98
|
+
data.value = cached.data;
|
|
99
|
+
error.value = undefined;
|
|
100
|
+
loading.value = false;
|
|
101
|
+
markReady();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let promise = inFlight.get(key);
|
|
105
|
+
if (promise === undefined) {
|
|
106
|
+
// Snapshot the signal: this.controller may have been set to undefined by dispose when the then-callback runs.
|
|
107
|
+
const requestSignal = controller.signal;
|
|
108
|
+
promise = Promise.resolve().then(() => source({ signal: requestSignal, key }));
|
|
109
|
+
inFlight.set(key, promise);
|
|
110
|
+
promise.then(() => {
|
|
111
|
+
inFlight.delete(key);
|
|
112
|
+
}, () => {
|
|
113
|
+
inFlight.delete(key);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const value = await promise;
|
|
118
|
+
if (disposed || token !== fetchToken)
|
|
119
|
+
return;
|
|
120
|
+
applySuccess(value, token);
|
|
121
|
+
}
|
|
122
|
+
catch (failure) {
|
|
123
|
+
if (disposed || token !== fetchToken)
|
|
124
|
+
return;
|
|
125
|
+
applyFailure(failure, token);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const refetch = () => {
|
|
129
|
+
if (disposed) {
|
|
130
|
+
throw createRuntimeError('VOR580', 'Resource is disposed');
|
|
131
|
+
}
|
|
132
|
+
fetchToken += 1;
|
|
133
|
+
const token = fetchToken;
|
|
134
|
+
cache.delete(key);
|
|
135
|
+
return run(token);
|
|
136
|
+
};
|
|
137
|
+
const invalidate = () => {
|
|
138
|
+
if (disposed)
|
|
139
|
+
return Promise.resolve();
|
|
140
|
+
cache.delete(key);
|
|
141
|
+
return refetch();
|
|
142
|
+
};
|
|
143
|
+
const mutate = async (updater) => {
|
|
144
|
+
if (disposed) {
|
|
145
|
+
throw createRuntimeError('VOR580', 'Resource is disposed');
|
|
146
|
+
}
|
|
147
|
+
const previous = data.value;
|
|
148
|
+
try {
|
|
149
|
+
const next = await updater(previous);
|
|
150
|
+
if (disposed)
|
|
151
|
+
return;
|
|
152
|
+
data.value = next;
|
|
153
|
+
cache.set(key, { data: next });
|
|
154
|
+
error.value = undefined;
|
|
155
|
+
}
|
|
156
|
+
catch (failure) {
|
|
157
|
+
if (disposed)
|
|
158
|
+
return;
|
|
159
|
+
data.value = previous;
|
|
160
|
+
error.value = failure;
|
|
161
|
+
throw failure;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const dispose = () => {
|
|
165
|
+
if (disposed)
|
|
166
|
+
return;
|
|
167
|
+
disposed = true;
|
|
168
|
+
controller?.abort();
|
|
169
|
+
controller = undefined;
|
|
170
|
+
fetchToken += 1;
|
|
171
|
+
loading.value = false;
|
|
172
|
+
// Clean the in-flight entry on cancellation: a pending promise that was aborted before source responded
|
|
173
|
+
// must not poison later requests for the same key (a new request would hang on a dead promise).
|
|
174
|
+
inFlight.delete(key);
|
|
175
|
+
// Disposal counts as settle (SSR await on whenReady does not hang).
|
|
176
|
+
markReady();
|
|
177
|
+
};
|
|
178
|
+
if (owner !== undefined) {
|
|
179
|
+
owner.own(dispose);
|
|
180
|
+
}
|
|
181
|
+
void run(fetchToken);
|
|
182
|
+
return {
|
|
183
|
+
key,
|
|
184
|
+
data,
|
|
185
|
+
error,
|
|
186
|
+
loading,
|
|
187
|
+
refetch,
|
|
188
|
+
invalidate,
|
|
189
|
+
mutate,
|
|
190
|
+
whenReady: () => ready,
|
|
191
|
+
dispose,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
export { createResourceLoader, isResourceAbortError, } from './loader.js';
|
|
195
|
+
/**
|
|
196
|
+
* Extracts a single Resource snapshot; returns undefined when data is not ready (undefined) or
|
|
197
|
+
* the default key (`resource:N`) is used (unstable across processes, SSR serialization must use explicit keys, see contract §8).
|
|
198
|
+
*/
|
|
199
|
+
export function serializeResourceState(resource) {
|
|
200
|
+
const data = resource.data.value;
|
|
201
|
+
if (data === undefined)
|
|
202
|
+
return undefined;
|
|
203
|
+
if (DEFAULT_KEY_PATTERN.test(resource.key))
|
|
204
|
+
return undefined;
|
|
205
|
+
return { key: resource.key, data };
|
|
206
|
+
}
|
|
207
|
+
/** Batch extraction; skips unready resources and default-key resources (the latter are reported via onWarning). */
|
|
208
|
+
export function serializeResourceStates(resources, options = {}) {
|
|
209
|
+
const snapshots = [];
|
|
210
|
+
for (const resource of resources) {
|
|
211
|
+
if (DEFAULT_KEY_PATTERN.test(resource.key)) {
|
|
212
|
+
options.onWarning?.(resource.key);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const snapshot = serializeResourceState(resource);
|
|
216
|
+
if (snapshot !== undefined)
|
|
217
|
+
snapshots.push(snapshot);
|
|
218
|
+
}
|
|
219
|
+
return snapshots;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Client hydration: builds a pre-filled cache from snapshots (a get hit returns immediately without calling source).
|
|
223
|
+
* Consistent with createResource's cache contract (ResourceCache stores CacheEntry items).
|
|
224
|
+
*/
|
|
225
|
+
export function createHydratedCache(snapshots) {
|
|
226
|
+
const store = new Map();
|
|
227
|
+
for (const snapshot of snapshots) {
|
|
228
|
+
store.set(snapshot.key, { data: snapshot.data });
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
get(key) {
|
|
232
|
+
return store.get(key);
|
|
233
|
+
},
|
|
234
|
+
set(key, entry) {
|
|
235
|
+
store.set(key, entry);
|
|
236
|
+
},
|
|
237
|
+
delete(key) {
|
|
238
|
+
return store.delete(key);
|
|
239
|
+
},
|
|
240
|
+
clear() {
|
|
241
|
+
store.clear();
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
export function createAction(source, options = {}) {
|
|
246
|
+
const owner = options.owner ?? currentOwner();
|
|
247
|
+
const pending = signal(false);
|
|
248
|
+
const error = signal(undefined);
|
|
249
|
+
const data = signal(undefined);
|
|
250
|
+
let controller;
|
|
251
|
+
let runToken = 0;
|
|
252
|
+
let disposed = false;
|
|
253
|
+
const run = async (args) => {
|
|
254
|
+
if (disposed) {
|
|
255
|
+
throw createRuntimeError('VOR580', 'Action is disposed');
|
|
256
|
+
}
|
|
257
|
+
controller?.abort();
|
|
258
|
+
controller = new AbortController();
|
|
259
|
+
const token = ++runToken;
|
|
260
|
+
const requestSignal = controller.signal;
|
|
261
|
+
pending.value = true;
|
|
262
|
+
try {
|
|
263
|
+
const value = await Promise.resolve().then(() => source({ signal: requestSignal, args }));
|
|
264
|
+
if (disposed || token !== runToken)
|
|
265
|
+
return value;
|
|
266
|
+
data.value = value;
|
|
267
|
+
error.value = undefined;
|
|
268
|
+
pending.value = false;
|
|
269
|
+
const invalidates = typeof options.invalidates === 'function'
|
|
270
|
+
? options.invalidates(args, value)
|
|
271
|
+
: options.invalidates;
|
|
272
|
+
if (invalidates !== undefined) {
|
|
273
|
+
for (const key of invalidates) {
|
|
274
|
+
defaultCacheStore.delete(key);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return value;
|
|
278
|
+
}
|
|
279
|
+
catch (failure) {
|
|
280
|
+
if (disposed || token !== runToken) {
|
|
281
|
+
if (isAbortError(failure))
|
|
282
|
+
return undefined;
|
|
283
|
+
throw failure;
|
|
284
|
+
}
|
|
285
|
+
if (isAbortError(failure)) {
|
|
286
|
+
// Cancelled: no error, not thrown to the caller (cancellation is cooperative, see contract §6).
|
|
287
|
+
pending.value = false;
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
error.value = failure;
|
|
291
|
+
pending.value = false;
|
|
292
|
+
throw failure;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const dispose = () => {
|
|
296
|
+
if (disposed)
|
|
297
|
+
return;
|
|
298
|
+
disposed = true;
|
|
299
|
+
controller?.abort();
|
|
300
|
+
controller = undefined;
|
|
301
|
+
runToken += 1;
|
|
302
|
+
pending.value = false;
|
|
303
|
+
};
|
|
304
|
+
if (owner !== undefined) {
|
|
305
|
+
owner.own(dispose);
|
|
306
|
+
}
|
|
307
|
+
return { pending, error, data, run, dispose };
|
|
308
|
+
}
|
package/dist/loader.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** @license MIT
|
|
2
|
+
* Copyright (c) 2026 vobsjs
|
|
3
|
+
* @vobs/resource
|
|
4
|
+
*/
|
|
5
|
+
import { type ResourceCache, type ResourceSource } from './index.js';
|
|
6
|
+
/**
|
|
7
|
+
* Resource → Router loader adapter (contract resource-action.md §6).
|
|
8
|
+
*
|
|
9
|
+
* Wraps a Resource into a function matching the RouteLoader signature; zero changes on the Router side:
|
|
10
|
+
* - key defaults to route.path (overridable), reusing Resource caching/dedup/cancellation/race protection;
|
|
11
|
+
* - an external signal (navigation cancellation) bridges into the Resource's internal cancellation;
|
|
12
|
+
* - clean dependency direction: this package does not depend on @vobs/router, only on structural types.
|
|
13
|
+
*/
|
|
14
|
+
/** Structural subset of the loader context the adapter accepts (aligned with @vobs/router RouteLoaderContext). */
|
|
15
|
+
export interface ResourceLoaderContext {
|
|
16
|
+
readonly route: {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
};
|
|
19
|
+
readonly signal?: AbortSignal;
|
|
20
|
+
readonly request?: unknown;
|
|
21
|
+
}
|
|
22
|
+
export interface ResourceLoaderOptions<T> {
|
|
23
|
+
/** Cache key; defaults to route.path. A function form derives it from the route. */
|
|
24
|
+
readonly key?: string | ((route: {
|
|
25
|
+
readonly path: string;
|
|
26
|
+
}) => string);
|
|
27
|
+
/** Custom cache (defaults to an in-process Map). */
|
|
28
|
+
readonly cache?: ResourceCache<T>;
|
|
29
|
+
}
|
|
30
|
+
export type ResourceLoader<T> = (context: ResourceLoaderContext) => Promise<T>;
|
|
31
|
+
export declare function createResourceLoader<T>(source: ResourceSource<T>, options?: ResourceLoaderOptions<T>): ResourceLoader<T>;
|
|
32
|
+
export declare function isResourceAbortError(error: unknown): boolean;
|
package/dist/loader.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** @license MIT
|
|
2
|
+
* Copyright (c) 2026 vobsjs
|
|
3
|
+
* @vobs/resource
|
|
4
|
+
*/
|
|
5
|
+
import { createResource } from './index.js';
|
|
6
|
+
function isAbortError(error) {
|
|
7
|
+
return (typeof error === 'object' &&
|
|
8
|
+
error !== null &&
|
|
9
|
+
error.name === 'AbortError');
|
|
10
|
+
}
|
|
11
|
+
function readResourceLoaderKey(policy, route) {
|
|
12
|
+
if (typeof policy === 'function')
|
|
13
|
+
return policy(route);
|
|
14
|
+
return policy ?? route.path;
|
|
15
|
+
}
|
|
16
|
+
export function createResourceLoader(source, options = {}) {
|
|
17
|
+
return (context) => {
|
|
18
|
+
const key = readResourceLoaderKey(options.key, context.route);
|
|
19
|
+
const resource = createResource(source, {
|
|
20
|
+
key,
|
|
21
|
+
...(options.cache === undefined ? {} : { cache: options.cache }),
|
|
22
|
+
});
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
let settled = false;
|
|
25
|
+
let externalAbortListener;
|
|
26
|
+
let unsubscribers;
|
|
27
|
+
const settle = (result) => {
|
|
28
|
+
if (settled)
|
|
29
|
+
return;
|
|
30
|
+
settled = true;
|
|
31
|
+
if (externalAbortListener !== undefined) {
|
|
32
|
+
context.signal?.removeEventListener('abort', externalAbortListener);
|
|
33
|
+
externalAbortListener = undefined;
|
|
34
|
+
}
|
|
35
|
+
if (unsubscribers !== undefined) {
|
|
36
|
+
for (const unsubscribe of unsubscribers)
|
|
37
|
+
unsubscribe();
|
|
38
|
+
unsubscribers = undefined;
|
|
39
|
+
}
|
|
40
|
+
resource.dispose();
|
|
41
|
+
result();
|
|
42
|
+
};
|
|
43
|
+
// external signal (navigation cancellation) → bridge into the Resource's internal cancellation
|
|
44
|
+
const onExternalAbort = () => {
|
|
45
|
+
settle(() => reject(createAbortError()));
|
|
46
|
+
};
|
|
47
|
+
if (context.signal !== undefined) {
|
|
48
|
+
if (context.signal.aborted) {
|
|
49
|
+
settle(() => reject(createAbortError()));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
context.signal.addEventListener('abort', onExternalAbort);
|
|
53
|
+
externalAbortListener = onExternalAbort;
|
|
54
|
+
}
|
|
55
|
+
// Watch the three states: settle when loading ends (data or error). onChange is the public subscription
|
|
56
|
+
// surface (P1 fix, replacing the previous type cast of ReadonlySignal).
|
|
57
|
+
const check = () => {
|
|
58
|
+
if (settled)
|
|
59
|
+
return;
|
|
60
|
+
if (resource.loading.value)
|
|
61
|
+
return;
|
|
62
|
+
const failure = resource.error.value;
|
|
63
|
+
if (failure !== undefined) {
|
|
64
|
+
settle(() => reject(toError(failure)));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
settle(() => resolve(resource.data.value));
|
|
68
|
+
};
|
|
69
|
+
unsubscribers = [
|
|
70
|
+
resource.data.onChange(check),
|
|
71
|
+
resource.error.onChange(check),
|
|
72
|
+
resource.loading.onChange(check),
|
|
73
|
+
];
|
|
74
|
+
check();
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function createAbortError() {
|
|
79
|
+
const error = new Error('Resource loader aborted');
|
|
80
|
+
error.name = 'AbortError';
|
|
81
|
+
return error;
|
|
82
|
+
}
|
|
83
|
+
/** Normalizes any failure value into an Error (promise rejection requires an Error). */
|
|
84
|
+
function toError(failure) {
|
|
85
|
+
if (isAbortError(failure))
|
|
86
|
+
return createAbortError();
|
|
87
|
+
if (failure instanceof Error)
|
|
88
|
+
return failure;
|
|
89
|
+
return new Error(String(failure));
|
|
90
|
+
}
|
|
91
|
+
export function isResourceAbortError(error) {
|
|
92
|
+
return isAbortError(error);
|
|
93
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
"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
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"module": "./dist/index.js",
|
|
36
|
+
"main": "./dist/index.js",
|
|
37
|
+
"sideEffects": false,
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20.19.0"
|
|
40
|
+
}
|
|
41
|
+
}
|