@marianmeres/fetch-store 3.0.4 → 3.1.1
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 +65 -255
- package/dist/fetch-store.d.ts +4 -3
- package/dist/fetch-store.js +138 -95
- package/dist/fetch-stream-store.d.ts +5 -4
- package/dist/fetch-stream-store.js +78 -28
- package/dist/types.d.ts +42 -24
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,321 +1,131 @@
|
|
|
1
1
|
# @marianmeres/fetch-store
|
|
2
2
|
|
|
3
|
-
[](https://jsr.io/@marianmeres/fetch-store)
|
|
4
|
+
[](https://www.npmjs.com/package/@marianmeres/fetch-store)
|
|
5
|
+
[](LICENSE)
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
operations with built-in state tracking for loading, errors, and success counts. Designed
|
|
8
|
-
for Svelte-compatible reactivity but works with any framework.
|
|
7
|
+
Reactive store for async fetch operations with loading/error/success state tracking, caching, polling, request deduplication, abort support, and streaming (SSE, WebSocket). Svelte-compatible but framework-agnostic.
|
|
9
8
|
|
|
10
|
-
##
|
|
9
|
+
## Installation
|
|
11
10
|
|
|
12
|
-
```
|
|
11
|
+
```bash
|
|
13
12
|
deno add jsr:@marianmeres/fetch-store
|
|
14
13
|
```
|
|
15
14
|
|
|
16
|
-
```
|
|
17
|
-
npm
|
|
15
|
+
```bash
|
|
16
|
+
npm install @marianmeres/fetch-store
|
|
18
17
|
```
|
|
19
18
|
|
|
20
|
-
##
|
|
19
|
+
## Usage
|
|
21
20
|
|
|
22
21
|
```typescript
|
|
23
|
-
// user-store.ts
|
|
24
22
|
import { createFetchStore } from "@marianmeres/fetch-store";
|
|
25
23
|
|
|
26
24
|
const userStore = createFetchStore(async (userId: string) => {
|
|
27
|
-
const
|
|
28
|
-
if (!
|
|
29
|
-
return await
|
|
25
|
+
const res = await fetch(`/api/users/${userId}`);
|
|
26
|
+
if (!res.ok) throw new Error("Failed to fetch user");
|
|
27
|
+
return (await res.json()) as User;
|
|
30
28
|
});
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
userStore.subscribe(({ data, isFetching, lastFetchError }) => {
|
|
31
|
+
// react to state changes
|
|
32
|
+
});
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
const { data, isFetching, lastFetchError } = userStore.get();
|
|
34
|
+
await userStore.fetch("123");
|
|
37
35
|
```
|
|
38
36
|
|
|
39
|
-
### Svelte
|
|
37
|
+
### Svelte
|
|
40
38
|
|
|
41
39
|
```svelte
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
import { userStore } from './user-store.ts';
|
|
46
|
-
|
|
47
|
-
export let userId;
|
|
48
|
-
onMount(() => userStore.fetch(userId));
|
|
40
|
+
<script lang="ts">
|
|
41
|
+
import { userStore } from "./user-store.ts";
|
|
42
|
+
userStore.fetch(userId);
|
|
49
43
|
</script>
|
|
50
44
|
|
|
51
45
|
{#if $userStore.isFetching}
|
|
52
46
|
<Spinner />
|
|
53
47
|
{:else if $userStore.lastFetchError}
|
|
54
48
|
<Error error={$userStore.lastFetchError} />
|
|
55
|
-
{:else}
|
|
49
|
+
{:else if $userStore.data}
|
|
56
50
|
<UserCard user={$userStore.data} />
|
|
57
51
|
{/if}
|
|
58
52
|
```
|
|
59
53
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
- **Reactive state management** - Subscribe to loading, error, and data states
|
|
63
|
-
- **Silent fetching** - Update data without triggering loading indicators
|
|
64
|
-
- **Fetch-once with caching** - Prevent redundant fetches within a time threshold
|
|
65
|
-
- **Polling support** - Built-in recursive fetching for real-time updates
|
|
66
|
-
- **Request deduplication** - Optionally return in-flight promises for concurrent calls
|
|
67
|
-
- **Abort support** - Cancel in-flight requests with AbortController integration
|
|
68
|
-
- **Stream support** - Handle SSE, WebSocket, or push-based data sources
|
|
69
|
-
|
|
70
|
-
## Abortable Requests
|
|
71
|
-
|
|
72
|
-
Enable automatic request cancellation when a new fetch starts:
|
|
54
|
+
### Abortable
|
|
73
55
|
|
|
74
56
|
```typescript
|
|
75
|
-
import { createFetchStore } from "@marianmeres/fetch-store";
|
|
76
|
-
|
|
77
57
|
const searchStore = createFetchStore(
|
|
78
|
-
async (
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
return await response.json();
|
|
58
|
+
async (q: string, signal: AbortSignal) => {
|
|
59
|
+
const r = await fetch(`/api/search?q=${q}`, { signal });
|
|
60
|
+
return (await r.json()) as Result[];
|
|
82
61
|
},
|
|
83
62
|
null,
|
|
84
|
-
null,
|
|
85
63
|
{ abortable: true }
|
|
86
64
|
);
|
|
87
65
|
|
|
88
|
-
//
|
|
89
|
-
searchStore.fetch("
|
|
90
|
-
searchStore.fetch("
|
|
91
|
-
searchStore.fetch("hel"); // Aborted
|
|
92
|
-
searchStore.fetch("hello"); // This one completes
|
|
93
|
-
|
|
94
|
-
// Manual abort
|
|
95
|
-
searchStore.abort();
|
|
66
|
+
searchStore.fetch("h"); // aborted
|
|
67
|
+
searchStore.fetch("he"); // aborted
|
|
68
|
+
searchStore.fetch("hello"); // completes
|
|
96
69
|
```
|
|
97
70
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Prevent duplicate requests when multiple calls happen while one is in-flight:
|
|
71
|
+
### Deduplication
|
|
101
72
|
|
|
102
73
|
```typescript
|
|
103
|
-
const
|
|
104
|
-
async (userId) => fetch(`/api/users/${userId}`).then(r => r.json()),
|
|
105
|
-
null,
|
|
106
|
-
null,
|
|
107
|
-
{ dedupeInflight: true }
|
|
108
|
-
);
|
|
74
|
+
const store = createFetchStore(worker, null, { dedupeInflight: true });
|
|
109
75
|
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
const p3 = userStore.fetch("123");
|
|
114
|
-
|
|
115
|
-
p1 === p2 && p2 === p3; // true
|
|
76
|
+
const a = store.fetch("x");
|
|
77
|
+
const b = store.fetch("x");
|
|
78
|
+
a === b; // true — one network call
|
|
116
79
|
```
|
|
117
80
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
For streaming data sources, use `createFetchStreamStore`:
|
|
81
|
+
### Cached / polled reads
|
|
121
82
|
|
|
122
83
|
```typescript
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const sseStore = createFetchStreamStore((emit, url) => {
|
|
126
|
-
const eventSource = new EventSource(url);
|
|
127
|
-
|
|
128
|
-
eventSource.onmessage = (event) => {
|
|
129
|
-
emit("data", JSON.parse(event.data));
|
|
130
|
-
};
|
|
84
|
+
// fire only if no successful fetch yet, or more than 60s old
|
|
85
|
+
await store.fetchOnce("123", 60_000);
|
|
131
86
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// Return cleanup function
|
|
137
|
-
return () => {
|
|
138
|
-
eventSource.close();
|
|
139
|
-
emit("end");
|
|
140
|
-
};
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
// Start streaming (with optional auto-restart after 5s delay)
|
|
144
|
-
const stop = sseStore.fetchStream(["/api/events"], 5000);
|
|
145
|
-
|
|
146
|
-
// Later: stop the stream
|
|
87
|
+
// poll every 5s, silent (no isFetching flicker)
|
|
88
|
+
const stop = store.fetchRecursive("123", 5_000);
|
|
89
|
+
// ...later
|
|
147
90
|
stop();
|
|
148
91
|
```
|
|
149
92
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
This major release includes the following breaking changes:
|
|
153
|
-
|
|
154
|
-
### Simplified Generic Types
|
|
155
|
-
|
|
156
|
-
The `FetchStore<T>` and `FetchStreamStore<T>` interfaces have been simplified from a
|
|
157
|
-
two-generic pattern to a single generic:
|
|
158
|
-
|
|
159
|
-
```typescript
|
|
160
|
-
// Before (v2.x)
|
|
161
|
-
interface FetchStore<T, V = T extends FetchStoreValue<infer D> ? D : T> { ... }
|
|
162
|
-
|
|
163
|
-
// After (v3.x)
|
|
164
|
-
interface FetchStore<T> extends StoreReadable<FetchStoreValue<T>> { ... }
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
**Migration:** If you were using explicit generic parameters, simply use the data type
|
|
168
|
-
directly: `FetchStore<User>` instead of `FetchStore<FetchStoreValue<User>, User>`.
|
|
169
|
-
|
|
170
|
-
### Simplified API Signature
|
|
171
|
-
|
|
172
|
-
The `createFetchStore` and `createFetchStreamStore` functions now have a simpler signature.
|
|
173
|
-
The `dataFactory` parameter has been moved into the `options` object:
|
|
174
|
-
|
|
175
|
-
```typescript
|
|
176
|
-
// Before (v2.x)
|
|
177
|
-
createFetchStore(fetchWorker, initial, dataFactory, options)
|
|
178
|
-
|
|
179
|
-
// After (v3.x)
|
|
180
|
-
createFetchStore(fetchWorker, initial, options)
|
|
181
|
-
// dataFactory is now in options: { dataFactory: (data, old) => transformedData }
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
**Migration:** Move your `dataFactory` from the third parameter into the options object:
|
|
93
|
+
### Streaming (SSE)
|
|
185
94
|
|
|
186
95
|
```typescript
|
|
187
|
-
|
|
188
|
-
const store = createFetchStore(
|
|
189
|
-
async () => fetchData(),
|
|
190
|
-
null,
|
|
191
|
-
(data, old) => ({ ...old, ...data }),
|
|
192
|
-
{ dedupeInflight: true }
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
// After
|
|
196
|
-
const store = createFetchStore(
|
|
197
|
-
async () => fetchData(),
|
|
198
|
-
null,
|
|
199
|
-
{
|
|
200
|
-
dataFactory: (data, old) => ({ ...old, ...data }),
|
|
201
|
-
dedupeInflight: true
|
|
202
|
-
}
|
|
203
|
-
);
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
### DataFactory Type Change
|
|
207
|
-
|
|
208
|
-
The `DataFactory<T>` type now receives properly typed data instead of `unknown`:
|
|
96
|
+
import { createFetchStreamStore } from "@marianmeres/fetch-store";
|
|
209
97
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
98
|
+
const feed = createFetchStreamStore<Message>((emit, url: string) => {
|
|
99
|
+
const es = new EventSource(url);
|
|
100
|
+
es.onmessage = (e) => emit("data", JSON.parse(e.data));
|
|
101
|
+
es.onerror = (e) => emit("error", e);
|
|
102
|
+
return () => es.close();
|
|
103
|
+
});
|
|
213
104
|
|
|
214
|
-
|
|
215
|
-
|
|
105
|
+
const stop = feed.fetchStream(["/api/events"]);
|
|
106
|
+
// ...later: closes the connection, sets isFetching: false
|
|
107
|
+
stop();
|
|
216
108
|
```
|
|
217
109
|
|
|
218
|
-
|
|
219
|
-
throughout the API.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
## API Reference
|
|
223
|
-
|
|
224
|
-
### `createFetchStore<T>(fetchWorker, initial?, options?)`
|
|
225
|
-
|
|
226
|
-
Creates a reactive store for async fetch operations.
|
|
227
|
-
|
|
228
|
-
**Parameters:**
|
|
229
|
-
|
|
230
|
-
| Parameter | Type | Default | Description |
|
|
231
|
-
|-----------|------|---------|-------------|
|
|
232
|
-
| `fetchWorker` | `(...args) => Promise<T>` | required | Async function performing the fetch. Must return data of type T. When `abortable: true`, receives `AbortSignal` as the last argument. |
|
|
233
|
-
| `initial` | `T \| null` | `null` | Initial data value |
|
|
234
|
-
| `options` | `FetchStoreOptions<T>` | `{}` | Configuration options |
|
|
235
|
-
|
|
236
|
-
**Options:**
|
|
237
|
-
|
|
238
|
-
| Option | Type | Default | Description |
|
|
239
|
-
|--------|------|---------|-------------|
|
|
240
|
-
| `fetchOnceDefaultThresholdMs` | `number` | `300000` | Default threshold (5 min) for `fetchOnce` before allowing re-fetch |
|
|
241
|
-
| `dedupeInflight` | `boolean` | `false` | If true, concurrent `fetch()` calls return the same promise |
|
|
242
|
-
| `abortable` | `boolean` | `false` | If true, creates AbortController for each fetch, aborting previous requests |
|
|
243
|
-
| `dataFactory` | `(data: T, old?: T) => T` | - | Transform fetched data (useful for merge strategies) |
|
|
244
|
-
| `onReset` | `() => void` | - | Callback invoked when `reset()` is called |
|
|
245
|
-
|
|
246
|
-
**Returns:** `FetchStore<T>`
|
|
247
|
-
|
|
248
|
-
### FetchStore Methods
|
|
249
|
-
|
|
250
|
-
| Method | Signature | Description |
|
|
251
|
-
|--------|-----------|-------------|
|
|
252
|
-
| `subscribe` | `(fn: (value) => void) => () => void` | Subscribe to store changes (Svelte-compatible) |
|
|
253
|
-
| `get` | `() => FetchStoreValue<T>` | Get current store value |
|
|
254
|
-
| `fetch` | `(...args) => Promise<T \| null>` | Execute fetch, sets `isFetching` to true |
|
|
255
|
-
| `fetchSilent` | `(...args) => Promise<T \| null>` | Execute fetch without updating `isFetching` |
|
|
256
|
-
| `fetchOnce` | `(args?, thresholdMs?) => Promise<T \| null>` | Fetch only if not already fetched or threshold passed |
|
|
257
|
-
| `fetchOnceSilent` | `(args?, thresholdMs?) => Promise<T \| null>` | Silent version of `fetchOnce` |
|
|
258
|
-
| `fetchRecursive` | `(args?, delayMs?) => () => void` | Start polling, returns cancel function |
|
|
259
|
-
| `reset` | `() => void` | Reset store to initial state |
|
|
260
|
-
| `resetError` | `() => void` | Clear `lastFetchError` only |
|
|
261
|
-
| `touch` | `(data?: T) => void` | Update timestamps (tricks `fetchOnce`), optionally set data |
|
|
262
|
-
| `abort` | `() => void` | Abort in-flight requests (requires `abortable: true`) |
|
|
263
|
-
| `getInternalDataStore` | `() => StoreLike<T>` | Access internal data store |
|
|
264
|
-
|
|
265
|
-
### FetchStoreValue Properties
|
|
266
|
-
|
|
267
|
-
The store value contains both data and metadata:
|
|
268
|
-
|
|
269
|
-
| Property | Type | Description |
|
|
270
|
-
|----------|------|-------------|
|
|
271
|
-
| `data` | `T \| null` | The fetched data (null before first fetch or after reset) |
|
|
272
|
-
| `isFetching` | `boolean` | Whether a fetch is in progress |
|
|
273
|
-
| `lastFetchStart` | `Date \| null` | When the last fetch started |
|
|
274
|
-
| `lastFetchEnd` | `Date \| null` | When the last fetch completed |
|
|
275
|
-
| `lastFetchError` | `Error \| null` | Error from the last fetch, if any |
|
|
276
|
-
| `lastFetchSilentError` | `Error \| null` | Error from the last silent fetch, if any |
|
|
277
|
-
| `successCounter` | `number` | Number of successful fetches |
|
|
278
|
-
|
|
279
|
-
### `createFetchStreamStore<T>(fetchStreamWorker, initial?, options?)`
|
|
280
|
-
|
|
281
|
-
Creates a reactive store for streaming data sources.
|
|
282
|
-
|
|
283
|
-
**Parameters:**
|
|
284
|
-
|
|
285
|
-
| Parameter | Type | Default | Description |
|
|
286
|
-
|-----------|------|---------|-------------|
|
|
287
|
-
| `fetchStreamWorker` | `(emit, ...args) => (() => void) \| void` | required | Worker receiving emit callback for events. Emit receives data of type T. Should return cleanup function. |
|
|
288
|
-
| `initial` | `T \| null` | `null` | Initial data value |
|
|
289
|
-
| `options` | `FetchStreamStoreOptions<T>` | `{}` | Configuration options |
|
|
290
|
-
|
|
291
|
-
**Options:**
|
|
110
|
+
## Features
|
|
292
111
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
112
|
+
- Reactive state: `data`, `isFetching`, `lastFetchError`, `successCounter`, timestamps
|
|
113
|
+
- `fetchSilent` — background refresh without loading indicator
|
|
114
|
+
- `fetchOnce` — TTL-cached fetch that joins any in-flight request
|
|
115
|
+
- `fetchRecursive` — polling with cancel
|
|
116
|
+
- `abortable` — automatic `AbortController` per call
|
|
117
|
+
- `dedupeInflight` — concurrent calls share one promise
|
|
118
|
+
- `dataFactory` — custom merge/transform strategies
|
|
119
|
+
- `createFetchStreamStore` — push-based sources (SSE, WebSocket, generators)
|
|
297
120
|
|
|
298
|
-
|
|
299
|
-
- `emit("data", value)` - Push new data
|
|
300
|
-
- `emit("error", error)` - Report an error
|
|
301
|
-
- `emit("end")` - Signal stream completion
|
|
121
|
+
## API
|
|
302
122
|
|
|
303
|
-
|
|
123
|
+
See [API.md](API.md) for the complete API reference.
|
|
304
124
|
|
|
305
|
-
|
|
125
|
+
## Changelog
|
|
306
126
|
|
|
307
|
-
|
|
308
|
-
|--------|-----------|-------------|
|
|
309
|
-
| `subscribe` | `(fn: (value) => void) => () => void` | Subscribe to store changes |
|
|
310
|
-
| `get` | `() => FetchStreamStoreValue<T>` | Get current store value |
|
|
311
|
-
| `fetchStream` | `(args?, recursiveDelayMs?) => () => void` | Start stream, returns stop function. If `recursiveDelayMs > 0`, restarts after "end" event. |
|
|
312
|
-
| `reset` | `() => void` | Reset store to initial state |
|
|
313
|
-
| `resetError` | `() => void` | Clear `lastFetchError` only |
|
|
314
|
-
| `getInternalDataStore` | `() => StoreLike<T>` | Access internal data store |
|
|
127
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
315
128
|
|
|
316
|
-
##
|
|
129
|
+
## License
|
|
317
130
|
|
|
318
|
-
|
|
319
|
-
- **Author:** Marian Meres
|
|
320
|
-
- **Repository:** https://github.com/marianmeres/fetch-store
|
|
321
|
-
- **License:** MIT
|
|
131
|
+
[MIT](LICENSE)
|
package/dist/fetch-store.d.ts
CHANGED
|
@@ -6,10 +6,11 @@ import type { FetchStore, FetchStoreOptions } from "./types.js";
|
|
|
6
6
|
* state tracking for loading, errors, and success counts.
|
|
7
7
|
*
|
|
8
8
|
* @template T - The type of data the store will hold
|
|
9
|
+
* @template A - The argument tuple accepted by the fetch worker
|
|
9
10
|
* @param fetchWorker - Async function that performs the actual fetch operation.
|
|
10
|
-
*
|
|
11
|
+
* When `abortable: true`, receives an `AbortSignal` as the last argument.
|
|
11
12
|
* @param initial - Initial data value (default: null)
|
|
12
|
-
* @param options - Configuration options including optional dataFactory
|
|
13
|
+
* @param options - Configuration options including optional `dataFactory`
|
|
13
14
|
* @returns A FetchStore instance with reactive subscription and fetch methods
|
|
14
15
|
*
|
|
15
16
|
* @example
|
|
@@ -30,4 +31,4 @@ import type { FetchStore, FetchStoreOptions } from "./types.js";
|
|
|
30
31
|
* await userStore.fetch("123");
|
|
31
32
|
* ```
|
|
32
33
|
*/
|
|
33
|
-
export declare const createFetchStore: <T>(fetchWorker: (...args:
|
|
34
|
+
export declare const createFetchStore: <T, A extends unknown[] = unknown[]>(fetchWorker: (...args: A) => Promise<T>, initial?: T | null, options?: Partial<FetchStoreOptions<T>>) => FetchStore<T, A>;
|
package/dist/fetch-store.js
CHANGED
|
@@ -11,10 +11,11 @@ const DEFAULT_OPTIONS = {
|
|
|
11
11
|
* state tracking for loading, errors, and success counts.
|
|
12
12
|
*
|
|
13
13
|
* @template T - The type of data the store will hold
|
|
14
|
+
* @template A - The argument tuple accepted by the fetch worker
|
|
14
15
|
* @param fetchWorker - Async function that performs the actual fetch operation.
|
|
15
|
-
*
|
|
16
|
+
* When `abortable: true`, receives an `AbortSignal` as the last argument.
|
|
16
17
|
* @param initial - Initial data value (default: null)
|
|
17
|
-
* @param options - Configuration options including optional dataFactory
|
|
18
|
+
* @param options - Configuration options including optional `dataFactory`
|
|
18
19
|
* @returns A FetchStore instance with reactive subscription and fetch methods
|
|
19
20
|
*
|
|
20
21
|
* @example
|
|
@@ -39,7 +40,7 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
|
|
|
39
40
|
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
40
41
|
const { fetchOnceDefaultThresholdMs, dedupeInflight, abortable } = mergedOptions;
|
|
41
42
|
const dataFactory = options.dataFactory;
|
|
42
|
-
//
|
|
43
|
+
// Factory for data transformation strategies (merge / deepmerge / set / ...)
|
|
43
44
|
const _createData = (data, old) => typeof dataFactory === "function" ? dataFactory(data, old ?? undefined) : data;
|
|
44
45
|
const _createMetaObj = () => ({
|
|
45
46
|
isFetching: false,
|
|
@@ -52,10 +53,13 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
|
|
|
52
53
|
const _dataStore = createStore(initial);
|
|
53
54
|
const _metaStore = createStore(_createMetaObj());
|
|
54
55
|
const { subscribe, get } = createDerivedStore([_dataStore, _metaStore], ([data, meta]) => ({ data, ...meta }));
|
|
55
|
-
//
|
|
56
|
+
// Generation token — bumped on reset(). Any in-flight fetch whose
|
|
57
|
+
// generation no longer matches discards its writes.
|
|
58
|
+
let _generation = 0;
|
|
59
|
+
// In-flight promise tracking (always populated, independent of dedupeInflight).
|
|
60
|
+
// Used internally by fetchOnce/fetchOnceSilent to join an existing request.
|
|
56
61
|
let _inflightPromise = null;
|
|
57
62
|
let _inflightSilentPromise = null;
|
|
58
|
-
// AbortController for cancellation
|
|
59
63
|
let _abortController = null;
|
|
60
64
|
let _abortControllerSilent = null;
|
|
61
65
|
const abort = () => {
|
|
@@ -68,19 +72,17 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
|
|
|
68
72
|
_abortControllerSilent = null;
|
|
69
73
|
}
|
|
70
74
|
};
|
|
75
|
+
const _isAbortError = (e) => e instanceof DOMException && e.name === "AbortError";
|
|
71
76
|
const fetch = (...rest) => {
|
|
72
|
-
|
|
73
|
-
if (dedupeInflight && _inflightPromise) {
|
|
77
|
+
if (dedupeInflight && _inflightPromise)
|
|
74
78
|
return _inflightPromise;
|
|
75
|
-
}
|
|
76
|
-
// If abortable, abort any previous request and create new controller
|
|
77
79
|
if (abortable) {
|
|
78
|
-
if (_abortController)
|
|
80
|
+
if (_abortController)
|
|
79
81
|
_abortController.abort();
|
|
80
|
-
}
|
|
81
82
|
_abortController = new AbortController();
|
|
82
83
|
}
|
|
83
|
-
const
|
|
84
|
+
const myController = _abortController;
|
|
85
|
+
const myGeneration = _generation;
|
|
84
86
|
const doFetch = async () => {
|
|
85
87
|
_metaStore.set({
|
|
86
88
|
..._metaStore.get(),
|
|
@@ -90,106 +92,128 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
|
|
|
90
92
|
lastFetchError: null,
|
|
91
93
|
});
|
|
92
94
|
let error = null;
|
|
93
|
-
let
|
|
95
|
+
let data;
|
|
94
96
|
try {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
? [...rest, currentController.signal]
|
|
98
|
-
: rest;
|
|
99
|
-
_dataStore.set(_createData(await fetchWorker(...args), _dataStore.get()));
|
|
100
|
-
newSuccessCounter++;
|
|
97
|
+
const args = (abortable && myController ? [...rest, myController.signal] : rest);
|
|
98
|
+
data = await fetchWorker(...args);
|
|
101
99
|
}
|
|
102
100
|
catch (e) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
if (_isAbortError(e)) {
|
|
102
|
+
// Discard if store was reset during await.
|
|
103
|
+
if (myGeneration !== _generation)
|
|
104
|
+
return null;
|
|
105
|
+
// Only clear isFetching if we're still the current request
|
|
106
|
+
// (otherwise a newer fetch owns the meta).
|
|
107
|
+
if (!abortable || myController === _abortController || _abortController === null) {
|
|
108
|
+
_metaStore.set({
|
|
109
|
+
..._metaStore.get(),
|
|
110
|
+
isFetching: false,
|
|
111
|
+
lastFetchEnd: new Date(),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
107
115
|
}
|
|
108
116
|
error = e instanceof Error ? e : new Error(String(e));
|
|
109
117
|
}
|
|
118
|
+
// Discard writes if reset happened during await.
|
|
119
|
+
if (myGeneration !== _generation)
|
|
120
|
+
return null;
|
|
121
|
+
// If abortable and a newer fetch has superseded us, drop the write
|
|
122
|
+
// entirely — the newer fetch will own data and meta.
|
|
123
|
+
if (abortable &&
|
|
124
|
+
myController !== _abortController &&
|
|
125
|
+
_abortController !== null) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (!error && data !== undefined) {
|
|
129
|
+
_dataStore.set(_createData(data, _dataStore.get()));
|
|
130
|
+
}
|
|
131
|
+
const prev = _metaStore.get();
|
|
110
132
|
_metaStore.set({
|
|
111
|
-
...
|
|
133
|
+
...prev,
|
|
112
134
|
isFetching: false,
|
|
113
135
|
lastFetchEnd: new Date(),
|
|
114
136
|
lastFetchError: error,
|
|
115
|
-
successCounter:
|
|
137
|
+
successCounter: error ? prev.successCounter : prev.successCounter + 1,
|
|
116
138
|
});
|
|
117
|
-
|
|
118
|
-
return _metaStore.get().lastFetchError ? null : _dataStore.get();
|
|
139
|
+
return error ? null : _dataStore.get();
|
|
119
140
|
};
|
|
120
|
-
|
|
121
|
-
_inflightPromise
|
|
141
|
+
const p = doFetch().finally(() => {
|
|
142
|
+
if (_inflightPromise === p)
|
|
122
143
|
_inflightPromise = null;
|
|
123
|
-
|
|
124
|
-
_abortController = null;
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
return _inflightPromise;
|
|
128
|
-
}
|
|
129
|
-
return doFetch().finally(() => {
|
|
130
|
-
if (currentController === _abortController) {
|
|
144
|
+
if (myController && myController === _abortController) {
|
|
131
145
|
_abortController = null;
|
|
132
146
|
}
|
|
133
147
|
});
|
|
148
|
+
_inflightPromise = p;
|
|
149
|
+
return p;
|
|
134
150
|
};
|
|
135
|
-
// Similar to fetch, but does
|
|
136
|
-
//
|
|
151
|
+
// Similar to fetch, but does NOT touch `isFetching` — avoids loading-spinner
|
|
152
|
+
// flicker for background refreshes. Still updates lastFetchStart,
|
|
153
|
+
// lastFetchEnd, successCounter and lastFetchSilentError so that
|
|
154
|
+
// fetchOnceSilent's threshold/cache logic works correctly.
|
|
137
155
|
const fetchSilent = (...rest) => {
|
|
138
|
-
|
|
139
|
-
if (dedupeInflight && _inflightSilentPromise) {
|
|
156
|
+
if (dedupeInflight && _inflightSilentPromise)
|
|
140
157
|
return _inflightSilentPromise;
|
|
141
|
-
}
|
|
142
|
-
// If abortable, abort any previous silent request and create new controller
|
|
143
158
|
if (abortable) {
|
|
144
|
-
if (_abortControllerSilent)
|
|
159
|
+
if (_abortControllerSilent)
|
|
145
160
|
_abortControllerSilent.abort();
|
|
146
|
-
}
|
|
147
161
|
_abortControllerSilent = new AbortController();
|
|
148
162
|
}
|
|
149
|
-
const
|
|
163
|
+
const myController = _abortControllerSilent;
|
|
164
|
+
const myGeneration = _generation;
|
|
165
|
+
const startedAt = new Date();
|
|
150
166
|
const doFetchSilent = async () => {
|
|
151
|
-
const currentMeta = _metaStore.get();
|
|
152
|
-
if (currentMeta.lastFetchSilentError) {
|
|
153
|
-
_metaStore.set({ ...currentMeta, lastFetchSilentError: null });
|
|
154
|
-
}
|
|
155
167
|
let error = null;
|
|
168
|
+
let data;
|
|
156
169
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
? [...rest, currentController.signal]
|
|
160
|
-
: rest;
|
|
161
|
-
_dataStore.set(_createData(await fetchWorker(...args), _dataStore.get()));
|
|
170
|
+
const args = (abortable && myController ? [...rest, myController.signal] : rest);
|
|
171
|
+
data = await fetchWorker(...args);
|
|
162
172
|
}
|
|
163
173
|
catch (e) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
174
|
+
if (_isAbortError(e)) {
|
|
175
|
+
// Discard if reset happened during await.
|
|
176
|
+
if (myGeneration !== _generation)
|
|
177
|
+
return null;
|
|
178
|
+
// Silent aborts do not touch meta at all — nothing to roll back.
|
|
179
|
+
return null;
|
|
167
180
|
}
|
|
168
181
|
error = e instanceof Error ? e : new Error(String(e));
|
|
169
182
|
}
|
|
170
|
-
if (
|
|
171
|
-
|
|
183
|
+
if (myGeneration !== _generation)
|
|
184
|
+
return null;
|
|
185
|
+
if (abortable &&
|
|
186
|
+
myController !== _abortControllerSilent &&
|
|
187
|
+
_abortControllerSilent !== null) {
|
|
188
|
+
return null;
|
|
172
189
|
}
|
|
173
|
-
//
|
|
174
|
-
|
|
190
|
+
// Write data BEFORE meta so subscribers see the new data alongside the
|
|
191
|
+
// new meta in the final notification.
|
|
192
|
+
if (!error && data !== undefined) {
|
|
193
|
+
_dataStore.set(_createData(data, _dataStore.get()));
|
|
194
|
+
}
|
|
195
|
+
const prev = _metaStore.get();
|
|
196
|
+
_metaStore.set({
|
|
197
|
+
...prev,
|
|
198
|
+
lastFetchStart: startedAt,
|
|
199
|
+
lastFetchEnd: new Date(),
|
|
200
|
+
lastFetchSilentError: error,
|
|
201
|
+
successCounter: error ? prev.successCounter : prev.successCounter + 1,
|
|
202
|
+
});
|
|
203
|
+
return error ? null : _dataStore.get();
|
|
175
204
|
};
|
|
176
|
-
|
|
177
|
-
_inflightSilentPromise
|
|
205
|
+
const p = doFetchSilent().finally(() => {
|
|
206
|
+
if (_inflightSilentPromise === p)
|
|
178
207
|
_inflightSilentPromise = null;
|
|
179
|
-
|
|
180
|
-
_abortControllerSilent = null;
|
|
181
|
-
}
|
|
182
|
-
});
|
|
183
|
-
return _inflightSilentPromise;
|
|
184
|
-
}
|
|
185
|
-
return doFetchSilent().finally(() => {
|
|
186
|
-
if (currentController === _abortControllerSilent) {
|
|
208
|
+
if (myController && myController === _abortControllerSilent) {
|
|
187
209
|
_abortControllerSilent = null;
|
|
188
210
|
}
|
|
189
211
|
});
|
|
212
|
+
_inflightSilentPromise = p;
|
|
213
|
+
return p;
|
|
190
214
|
};
|
|
191
215
|
// Sets internal timestamps to now, tricking fetchOnce into thinking data was just fetched.
|
|
192
|
-
// Useful for external cache invalidation.
|
|
216
|
+
// Useful for external cache invalidation. Increments successCounter as a side effect.
|
|
193
217
|
const touch = (data) => {
|
|
194
218
|
if (data !== undefined) {
|
|
195
219
|
_dataStore.set(data);
|
|
@@ -203,15 +227,20 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
|
|
|
203
227
|
successCounter: currentMeta.successCounter + 1,
|
|
204
228
|
});
|
|
205
229
|
};
|
|
230
|
+
const _normalizeArgs = (a) => (Array.isArray(a) ? a : [a]);
|
|
206
231
|
const _fetchOnce = async (isSilent, fetchArgs = [], thresholdMs = fetchOnceDefaultThresholdMs) => {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
232
|
+
const args = _normalizeArgs(fetchArgs);
|
|
233
|
+
// Always join any in-flight request (regardless of dedupeInflight).
|
|
234
|
+
// Prevents concurrent fetchOnce calls from each firing a fetch.
|
|
235
|
+
if (_inflightPromise)
|
|
236
|
+
return await _inflightPromise;
|
|
237
|
+
if (_inflightSilentPromise)
|
|
238
|
+
return await _inflightSilentPromise;
|
|
239
|
+
const { successCounter, lastFetchStart } = _metaStore.get();
|
|
240
|
+
if (!successCounter) {
|
|
210
241
|
return isSilent ? await fetchSilent(...args) : await fetch(...args);
|
|
211
242
|
}
|
|
212
|
-
// Expired threshold?
|
|
213
243
|
if (thresholdMs &&
|
|
214
|
-
!isFetching &&
|
|
215
244
|
lastFetchStart &&
|
|
216
245
|
Date.now() - lastFetchStart.valueOf() > thresholdMs) {
|
|
217
246
|
return isSilent ? await fetchSilent(...args) : await fetch(...args);
|
|
@@ -225,31 +254,41 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
|
|
|
225
254
|
const fetchOnceSilent = async (fetchArgs = [], thresholdMs = fetchOnceDefaultThresholdMs) => {
|
|
226
255
|
return await _fetchOnce(true, fetchArgs, thresholdMs);
|
|
227
256
|
};
|
|
228
|
-
// Recursive polling (
|
|
229
|
-
const fetchRecursive = (fetchArgs = [], delayMs = 500) => {
|
|
257
|
+
// Recursive polling (long/short polling depends on the server)
|
|
258
|
+
const fetchRecursive = (fetchArgs = [], delayMs = 500, opts = {}) => {
|
|
259
|
+
const useSilent = opts.silent !== false;
|
|
260
|
+
const runner = useSilent ? fetchSilent : fetch;
|
|
230
261
|
let _timer;
|
|
231
262
|
let _aborted = false;
|
|
232
|
-
const _fetchRecursive = (args
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
263
|
+
const _fetchRecursive = (args) => {
|
|
264
|
+
if (_aborted)
|
|
265
|
+
return;
|
|
266
|
+
runner(...args).then(() => {
|
|
267
|
+
if (_aborted)
|
|
268
|
+
return;
|
|
269
|
+
const resolvedDelay = typeof delayMs === "function" ? delayMs() : delayMs;
|
|
236
270
|
if (_timer)
|
|
237
271
|
clearTimeout(_timer);
|
|
238
|
-
if (resolvedDelay > 0
|
|
239
|
-
_timer = setTimeout(() =>
|
|
272
|
+
if (resolvedDelay > 0) {
|
|
273
|
+
_timer = setTimeout(() => {
|
|
274
|
+
if (!_aborted)
|
|
275
|
+
_fetchRecursive(args);
|
|
276
|
+
}, resolvedDelay);
|
|
240
277
|
}
|
|
241
278
|
});
|
|
242
|
-
// Return a "cancel" (or "stop") control fn
|
|
243
|
-
return () => {
|
|
244
|
-
if (_timer)
|
|
245
|
-
clearTimeout(_timer);
|
|
246
|
-
_aborted = true;
|
|
247
|
-
};
|
|
248
279
|
};
|
|
249
|
-
|
|
280
|
+
const normalized = _normalizeArgs(fetchArgs);
|
|
281
|
+
_fetchRecursive(normalized);
|
|
282
|
+
return () => {
|
|
283
|
+
_aborted = true;
|
|
284
|
+
if (_timer)
|
|
285
|
+
clearTimeout(_timer);
|
|
286
|
+
};
|
|
250
287
|
};
|
|
251
|
-
// Resets the store to initial state; useful for cache invalidation
|
|
288
|
+
// Resets the store to initial state; useful for cache invalidation.
|
|
289
|
+
// Bumps generation so any late-resolving fetch cannot overwrite reset state.
|
|
252
290
|
const reset = () => {
|
|
291
|
+
_generation++;
|
|
253
292
|
abort();
|
|
254
293
|
_dataStore.set(initial);
|
|
255
294
|
_metaStore.set(_createMetaObj());
|
|
@@ -259,7 +298,11 @@ export const createFetchStore = (fetchWorker, initial = null, options = {}) => {
|
|
|
259
298
|
mergedOptions.onReset();
|
|
260
299
|
};
|
|
261
300
|
const resetError = () => {
|
|
262
|
-
_metaStore.update((old) => ({
|
|
301
|
+
_metaStore.update((old) => ({
|
|
302
|
+
...old,
|
|
303
|
+
lastFetchError: null,
|
|
304
|
+
lastFetchSilentError: null,
|
|
305
|
+
}));
|
|
263
306
|
};
|
|
264
307
|
return {
|
|
265
308
|
subscribe,
|
|
@@ -5,9 +5,10 @@ import type { FetchStreamEventName, FetchStreamStore, FetchStreamStoreOptions }
|
|
|
5
5
|
* or any push-based data pattern.
|
|
6
6
|
*
|
|
7
7
|
* @template T - The type of data the store will hold
|
|
8
|
+
* @template A - The argument tuple accepted by the stream worker (after `emit`)
|
|
8
9
|
* @param fetchStreamWorker - Worker function that receives an emit callback for sending events.
|
|
9
|
-
* The emit callback receives data of type T. Should return a cleanup/abort function,
|
|
10
|
-
* if no cleanup is needed.
|
|
10
|
+
* The emit callback receives data of type T or Error. Should return a cleanup/abort function,
|
|
11
|
+
* or void if no cleanup is needed.
|
|
11
12
|
* @param initial - Initial data value (default: null)
|
|
12
13
|
* @param options - Configuration options including optional dataFactory for merge strategies
|
|
13
14
|
* @returns A FetchStreamStore instance with reactive subscription and stream control
|
|
@@ -22,7 +23,7 @@ import type { FetchStreamEventName, FetchStreamStore, FetchStreamStoreOptions }
|
|
|
22
23
|
* });
|
|
23
24
|
*
|
|
24
25
|
* const stop = sseStore.fetchStream(["/api/events"]);
|
|
25
|
-
* // Later: stop()
|
|
26
|
+
* // Later: stop() closes the connection and the run (isFetching: false, lastFetchEnd)
|
|
26
27
|
* ```
|
|
27
28
|
*/
|
|
28
|
-
export declare const createFetchStreamStore: <T>(fetchStreamWorker: (emit: (eventName: FetchStreamEventName, eventData?: T | Error) => void, ...args:
|
|
29
|
+
export declare const createFetchStreamStore: <T, A extends unknown[] = unknown[]>(fetchStreamWorker: (emit: (eventName: FetchStreamEventName, eventData?: T | Error) => void, ...args: A) => (() => void) | void, initial?: T | null, options?: Partial<FetchStreamStoreOptions<T>>) => FetchStreamStore<T, A>;
|
|
@@ -5,9 +5,10 @@ const DEFAULT_OPTIONS = {};
|
|
|
5
5
|
* or any push-based data pattern.
|
|
6
6
|
*
|
|
7
7
|
* @template T - The type of data the store will hold
|
|
8
|
+
* @template A - The argument tuple accepted by the stream worker (after `emit`)
|
|
8
9
|
* @param fetchStreamWorker - Worker function that receives an emit callback for sending events.
|
|
9
|
-
* The emit callback receives data of type T. Should return a cleanup/abort function,
|
|
10
|
-
* if no cleanup is needed.
|
|
10
|
+
* The emit callback receives data of type T or Error. Should return a cleanup/abort function,
|
|
11
|
+
* or void if no cleanup is needed.
|
|
11
12
|
* @param initial - Initial data value (default: null)
|
|
12
13
|
* @param options - Configuration options including optional dataFactory for merge strategies
|
|
13
14
|
* @returns A FetchStreamStore instance with reactive subscription and stream control
|
|
@@ -22,13 +23,13 @@ const DEFAULT_OPTIONS = {};
|
|
|
22
23
|
* });
|
|
23
24
|
*
|
|
24
25
|
* const stop = sseStore.fetchStream(["/api/events"]);
|
|
25
|
-
* // Later: stop()
|
|
26
|
+
* // Later: stop() closes the connection and the run (isFetching: false, lastFetchEnd)
|
|
26
27
|
* ```
|
|
27
28
|
*/
|
|
28
29
|
export const createFetchStreamStore = (fetchStreamWorker, initial = null, options = {}) => {
|
|
29
30
|
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
30
31
|
const dataFactory = options.dataFactory;
|
|
31
|
-
//
|
|
32
|
+
// Factory for data transformation strategies (merge / deepmerge / set / ...)
|
|
32
33
|
const _createData = (data, old) => typeof dataFactory === "function" ? dataFactory(data, old ?? undefined) : data;
|
|
33
34
|
const _createMetaObj = () => ({
|
|
34
35
|
isFetching: false,
|
|
@@ -39,26 +40,46 @@ export const createFetchStreamStore = (fetchStreamWorker, initial = null, option
|
|
|
39
40
|
const _dataStore = createStore(initial);
|
|
40
41
|
const _metaStore = createStore(_createMetaObj());
|
|
41
42
|
const { subscribe, get } = createDerivedStore([_dataStore, _metaStore], ([data, meta]) => ({ data, ...meta }));
|
|
43
|
+
// Tracks the stop fn of the currently running stream so reset() and a new
|
|
44
|
+
// fetchStream() can stop it.
|
|
45
|
+
let _currentStop = null;
|
|
42
46
|
const fetchStream = (fetchArgs = [], recursiveDelayMs = 0) => {
|
|
43
47
|
let _timer;
|
|
44
48
|
let _aborted = false;
|
|
45
49
|
let _abortFn;
|
|
46
|
-
//
|
|
47
|
-
|
|
50
|
+
// `closeRun` is false when the caller is about to overwrite meta anyway
|
|
51
|
+
// (reset, or a new fetchStream superseding this one), so subscribers see no
|
|
52
|
+
// intermediate `isFetching: false` flicker.
|
|
53
|
+
const stop = (closeRun) => {
|
|
54
|
+
if (_aborted)
|
|
55
|
+
return;
|
|
56
|
+
_aborted = true;
|
|
57
|
+
if (_timer)
|
|
58
|
+
clearTimeout(_timer);
|
|
48
59
|
if (typeof _abortFn === "function") {
|
|
49
|
-
|
|
60
|
+
try {
|
|
61
|
+
_abortFn();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// cleanup should not throw, but guard anyway
|
|
65
|
+
}
|
|
50
66
|
}
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
if (_currentStop === stop)
|
|
68
|
+
_currentStop = null;
|
|
69
|
+
// Every emit from here on is ignored (including an "end" from the cleanup
|
|
70
|
+
// above), so the store closes the run itself. Nothing to close when idle
|
|
71
|
+
// between recursive runs.
|
|
72
|
+
const meta = _metaStore.get();
|
|
73
|
+
if (closeRun && meta.isFetching) {
|
|
74
|
+
_metaStore.set({ ...meta, isFetching: false, lastFetchEnd: new Date() });
|
|
53
75
|
}
|
|
54
|
-
if (_timer)
|
|
55
|
-
clearTimeout(_timer);
|
|
56
|
-
_aborted = true;
|
|
57
76
|
};
|
|
58
|
-
//
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
// Public cancel fn takes no args (safe to pass directly as an event handler).
|
|
78
|
+
const cancel = () => stop(true);
|
|
79
|
+
const normalizedArgs = (Array.isArray(fetchArgs) ? fetchArgs : [fetchArgs]);
|
|
80
|
+
const runOnce = () => {
|
|
81
|
+
if (_aborted)
|
|
82
|
+
return;
|
|
62
83
|
_metaStore.set({
|
|
63
84
|
..._metaStore.get(),
|
|
64
85
|
isFetching: true,
|
|
@@ -68,17 +89,29 @@ export const createFetchStreamStore = (fetchStreamWorker, initial = null, option
|
|
|
68
89
|
});
|
|
69
90
|
try {
|
|
70
91
|
_abortFn = fetchStreamWorker((eventName, eventData) => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (eventName === "data" &&
|
|
92
|
+
// Ignore any events after cancellation.
|
|
93
|
+
if (_aborted)
|
|
94
|
+
return;
|
|
95
|
+
if (eventName === "data" &&
|
|
96
|
+
eventData !== undefined &&
|
|
97
|
+
!(eventData instanceof Error)) {
|
|
98
|
+
// A data event implicitly clears a prior error.
|
|
99
|
+
if (_metaStore.get().lastFetchError) {
|
|
100
|
+
_metaStore.set({
|
|
101
|
+
..._metaStore.get(),
|
|
102
|
+
lastFetchError: null,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
75
105
|
_dataStore.set(_createData(eventData, _dataStore.get()));
|
|
76
106
|
}
|
|
77
107
|
else if (eventName === "error") {
|
|
78
108
|
const error = eventData instanceof Error
|
|
79
109
|
? eventData
|
|
80
110
|
: new Error(String(eventData));
|
|
81
|
-
_metaStore.set({
|
|
111
|
+
_metaStore.set({
|
|
112
|
+
..._metaStore.get(),
|
|
113
|
+
lastFetchError: error,
|
|
114
|
+
});
|
|
82
115
|
}
|
|
83
116
|
else if (eventName === "end") {
|
|
84
117
|
_metaStore.set({
|
|
@@ -86,28 +119,45 @@ export const createFetchStreamStore = (fetchStreamWorker, initial = null, option
|
|
|
86
119
|
isFetching: false,
|
|
87
120
|
lastFetchEnd: new Date(),
|
|
88
121
|
});
|
|
89
|
-
|
|
122
|
+
const delay = typeof recursiveDelayMs === "function"
|
|
123
|
+
? recursiveDelayMs()
|
|
124
|
+
: recursiveDelayMs;
|
|
90
125
|
if (delay > 0 && !_aborted) {
|
|
91
126
|
if (_timer)
|
|
92
127
|
clearTimeout(_timer);
|
|
93
128
|
_timer = setTimeout(() => {
|
|
94
|
-
if (!_aborted)
|
|
95
|
-
|
|
96
|
-
}
|
|
129
|
+
if (!_aborted)
|
|
130
|
+
runOnce();
|
|
97
131
|
}, delay);
|
|
98
132
|
}
|
|
99
133
|
}
|
|
100
134
|
}, ...normalizedArgs);
|
|
101
135
|
}
|
|
102
136
|
catch (e) {
|
|
137
|
+
// Worker threw synchronously — reset isFetching and store the error.
|
|
103
138
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
104
|
-
_metaStore.set({
|
|
139
|
+
_metaStore.set({
|
|
140
|
+
..._metaStore.get(),
|
|
141
|
+
isFetching: false,
|
|
142
|
+
lastFetchEnd: new Date(),
|
|
143
|
+
lastFetchError: error,
|
|
144
|
+
});
|
|
105
145
|
}
|
|
106
|
-
return _abort;
|
|
107
146
|
};
|
|
108
|
-
|
|
147
|
+
// If a previous stream was still running, stop it before starting a new one.
|
|
148
|
+
if (_currentStop)
|
|
149
|
+
_currentStop(false);
|
|
150
|
+
_currentStop = stop;
|
|
151
|
+
runOnce();
|
|
152
|
+
return cancel;
|
|
109
153
|
};
|
|
110
154
|
const reset = () => {
|
|
155
|
+
// Stop any running stream (calls the worker's cleanup function).
|
|
156
|
+
if (_currentStop) {
|
|
157
|
+
const s = _currentStop;
|
|
158
|
+
_currentStop = null;
|
|
159
|
+
s(false);
|
|
160
|
+
}
|
|
111
161
|
_dataStore.set(initial);
|
|
112
162
|
_metaStore.set(_createMetaObj());
|
|
113
163
|
if (typeof mergedOptions.onReset === "function")
|
package/dist/types.d.ts
CHANGED
|
@@ -14,20 +14,20 @@ export type DataFactory<T> = (data: T, old?: T) => T;
|
|
|
14
14
|
* Contains common fields for tracking fetch operation state.
|
|
15
15
|
*/
|
|
16
16
|
export interface FetchMetaBase {
|
|
17
|
-
/** Whether a fetch operation is currently in progress */
|
|
17
|
+
/** Whether a `fetch` operation is currently in progress (silent fetches do not flip this) */
|
|
18
18
|
isFetching: boolean;
|
|
19
19
|
/** Timestamp when the last fetch operation started */
|
|
20
20
|
lastFetchStart: Date | null;
|
|
21
21
|
/** Timestamp when the last fetch operation completed */
|
|
22
22
|
lastFetchEnd: Date | null;
|
|
23
|
-
/** Error from the last fetch operation, if any */
|
|
23
|
+
/** Error from the last `fetch` operation, if any */
|
|
24
24
|
lastFetchError: Error | null;
|
|
25
25
|
}
|
|
26
26
|
/**
|
|
27
27
|
* Extended metadata for FetchStore including success tracking and silent fetch errors.
|
|
28
28
|
*/
|
|
29
29
|
export interface FetchStoreMeta extends FetchMetaBase {
|
|
30
|
-
/** Count of successful fetch operations (incremented on each success) */
|
|
30
|
+
/** Count of successful fetch operations (incremented on each success, silent or not) */
|
|
31
31
|
successCounter: number;
|
|
32
32
|
/** Error from the last silent fetch operation, if any */
|
|
33
33
|
lastFetchSilentError: Error | null;
|
|
@@ -62,66 +62,80 @@ export interface FetchStreamStoreValue<T> extends FetchStreamStoreMeta {
|
|
|
62
62
|
* Extends StoreReadable for Svelte-compatible subscribe functionality.
|
|
63
63
|
*
|
|
64
64
|
* @template T - The data type stored in the fetch store
|
|
65
|
+
* @template A - The argument tuple accepted by the fetch worker
|
|
65
66
|
*/
|
|
66
|
-
export interface FetchStore<T> extends StoreReadable<FetchStoreValue<T>> {
|
|
67
|
+
export interface FetchStore<T, A extends unknown[] = unknown[]> extends StoreReadable<FetchStoreValue<T>> {
|
|
67
68
|
/**
|
|
68
69
|
* Executes the fetch worker and updates store state.
|
|
69
70
|
* Sets `isFetching` to true during the operation.
|
|
70
71
|
* @param args - Arguments passed to the fetch worker
|
|
71
72
|
* @returns Promise resolving to fetched data or null on error
|
|
72
73
|
*/
|
|
73
|
-
fetch: (...args:
|
|
74
|
+
fetch: (...args: A) => Promise<T | null>;
|
|
74
75
|
/**
|
|
75
76
|
* Like `fetch`, but does not set `isFetching` to true.
|
|
76
77
|
* Useful for background refreshes without showing loading spinners.
|
|
78
|
+
* Still updates `lastFetchStart`, `lastFetchEnd`, and `successCounter`.
|
|
77
79
|
* @param args - Arguments passed to the fetch worker
|
|
78
80
|
* @returns Promise resolving to fetched data or null on error
|
|
79
81
|
*/
|
|
80
|
-
fetchSilent: (...args:
|
|
82
|
+
fetchSilent: (...args: A) => Promise<T | null>;
|
|
81
83
|
/**
|
|
82
84
|
* Fetches data only if not already fetched or if the threshold has passed.
|
|
83
|
-
*
|
|
84
|
-
* @param args - Arguments passed to the fetch worker
|
|
85
|
-
* @param thresholdMs - Time in ms before re-fetching is allowed (default from options)
|
|
85
|
+
* If a fetch (silent or not) is currently in-flight, joins it instead of firing a new one.
|
|
86
|
+
* @param args - Arguments passed to the fetch worker (pass as array or single value)
|
|
87
|
+
* @param thresholdMs - Time in ms before re-fetching is allowed (default from options).
|
|
88
|
+
* Use `0` to skip the threshold check and rely only on `successCounter`.
|
|
86
89
|
* @returns Promise resolving to fetched data or null on error
|
|
87
90
|
*/
|
|
88
|
-
fetchOnce: (args?: unknown
|
|
91
|
+
fetchOnce: (args?: A | unknown, thresholdMs?: number) => Promise<T | null>;
|
|
89
92
|
/**
|
|
90
93
|
* Like `fetchOnce`, but uses silent fetching (no `isFetching` state change).
|
|
91
94
|
* @param args - Arguments passed to the fetch worker
|
|
92
95
|
* @param thresholdMs - Time in ms before re-fetching is allowed (default from options)
|
|
93
96
|
* @returns Promise resolving to fetched data or null on error
|
|
94
97
|
*/
|
|
95
|
-
fetchOnceSilent: (args?: unknown
|
|
98
|
+
fetchOnceSilent: (args?: A | unknown, thresholdMs?: number) => Promise<T | null>;
|
|
96
99
|
/**
|
|
97
|
-
* Starts recursive polling
|
|
100
|
+
* Starts recursive polling.
|
|
98
101
|
* Each fetch waits for the previous one to complete before scheduling the next.
|
|
102
|
+
* By default uses `fetchSilent` so the UI does not flash loading state between polls.
|
|
99
103
|
* @param args - Arguments passed to the fetch worker
|
|
100
|
-
* @param delayMs - Delay between fetches in ms, or function returning delay
|
|
104
|
+
* @param delayMs - Delay between fetches in ms, or function returning delay (returning `0` stops)
|
|
105
|
+
* @param options - Optional overrides. `silent: false` uses `fetch` instead of `fetchSilent`.
|
|
101
106
|
* @returns Cancel function to stop the polling
|
|
102
107
|
*/
|
|
103
|
-
fetchRecursive: (args?: unknown
|
|
108
|
+
fetchRecursive: (args?: A | unknown, delayMs?: number | (() => number), options?: {
|
|
109
|
+
silent?: boolean;
|
|
110
|
+
}) => () => void;
|
|
104
111
|
/**
|
|
105
112
|
* Resets the store to its initial state, clearing all data and metadata.
|
|
106
|
-
*
|
|
113
|
+
* Aborts in-flight requests when `abortable: true`.
|
|
114
|
+
* Also bumps an internal generation token so that any non-abortable in-flight
|
|
115
|
+
* request (or a worker that ignores the abort signal) cannot overwrite the
|
|
116
|
+
* reset state after it resolves.
|
|
107
117
|
*/
|
|
108
118
|
reset: () => void;
|
|
109
119
|
/**
|
|
110
|
-
* Clears only the `lastFetchError`
|
|
120
|
+
* Clears only the `lastFetchError` and `lastFetchSilentError` fields
|
|
121
|
+
* without resetting other state.
|
|
111
122
|
*/
|
|
112
123
|
resetError: () => void;
|
|
113
124
|
/**
|
|
114
125
|
* Returns the internal data store for direct manipulation.
|
|
115
|
-
* Use with caution
|
|
126
|
+
* Use with caution: bypasses `dataFactory` and metadata tracking.
|
|
116
127
|
*/
|
|
117
128
|
getInternalDataStore: () => StoreLike<T | null>;
|
|
118
129
|
/**
|
|
119
130
|
* The raw fetch worker function passed to createFetchStore.
|
|
120
131
|
*/
|
|
121
|
-
fetchWorker: (...args:
|
|
132
|
+
fetchWorker: (...args: A) => Promise<T>;
|
|
122
133
|
/**
|
|
123
134
|
* Updates internal timestamps to trick `fetchOnce` into thinking data was just fetched.
|
|
124
135
|
* Optionally sets new data. Useful for external cache invalidation.
|
|
136
|
+
*
|
|
137
|
+
* Note: increments `successCounter` — consumers watching the counter should not
|
|
138
|
+
* assume every increment corresponds to a network success.
|
|
125
139
|
* @param data - Optional new data to set
|
|
126
140
|
*/
|
|
127
141
|
touch: (data?: T) => void;
|
|
@@ -140,30 +154,34 @@ export interface FetchStore<T> extends StoreReadable<FetchStoreValue<T>> {
|
|
|
140
154
|
export type FetchStreamEventName = "data" | "error" | "end";
|
|
141
155
|
/**
|
|
142
156
|
* Emit function passed to the stream worker for sending events.
|
|
157
|
+
* @template T - The data type carried by "data" events
|
|
143
158
|
* @param eventName - The type of event being emitted
|
|
144
159
|
* @param eventData - The event payload (data chunk or error)
|
|
145
160
|
*/
|
|
146
|
-
export type FetchStreamEventEmitFn = (eventName: FetchStreamEventName, eventData?:
|
|
161
|
+
export type FetchStreamEventEmitFn<T = unknown> = (eventName: FetchStreamEventName, eventData?: T | Error) => void;
|
|
147
162
|
/**
|
|
148
163
|
* FetchStreamStore interface for handling streaming data sources.
|
|
149
164
|
* Useful for SSE, WebSocket, or any streaming data pattern.
|
|
150
165
|
*
|
|
151
166
|
* @template T - The data type stored in the stream store
|
|
167
|
+
* @template A - The argument tuple accepted by the fetch-stream worker (after `emit`)
|
|
152
168
|
*/
|
|
153
|
-
export interface FetchStreamStore<T> extends StoreReadable<FetchStreamStoreValue<T>> {
|
|
169
|
+
export interface FetchStreamStore<T, A extends unknown[] = unknown[]> extends StoreReadable<FetchStreamStoreValue<T>> {
|
|
154
170
|
/**
|
|
155
171
|
* Starts the stream and optionally restarts it after completion.
|
|
156
172
|
* @param args - Arguments passed to the stream worker
|
|
157
173
|
* @param recursiveDelayMs - Delay before restarting stream after "end" event (0 = no restart)
|
|
158
|
-
* @returns Cancel function
|
|
174
|
+
* @returns Cancel function: calls the worker's cleanup, ignores any later emits,
|
|
175
|
+
* and closes the run (`isFetching: false`, `lastFetchEnd`) if it was still active.
|
|
159
176
|
*/
|
|
160
|
-
fetchStream: (args?: unknown
|
|
177
|
+
fetchStream: (args?: A | unknown, recursiveDelayMs?: number | (() => number)) => () => void;
|
|
161
178
|
/**
|
|
162
179
|
* The raw stream worker function passed to createFetchStreamStore.
|
|
163
180
|
*/
|
|
164
|
-
fetchStreamWorker: (emit: FetchStreamEventEmitFn
|
|
181
|
+
fetchStreamWorker: (emit: FetchStreamEventEmitFn<T>, ...args: A) => (() => void) | void;
|
|
165
182
|
/**
|
|
166
183
|
* Resets the store to its initial state, clearing all data and metadata.
|
|
184
|
+
* Also stops any currently running stream (calls the worker's cleanup function).
|
|
167
185
|
*/
|
|
168
186
|
reset: () => void;
|
|
169
187
|
/**
|
|
@@ -172,7 +190,7 @@ export interface FetchStreamStore<T> extends StoreReadable<FetchStreamStoreValue
|
|
|
172
190
|
resetError: () => void;
|
|
173
191
|
/**
|
|
174
192
|
* Returns the internal data store for direct manipulation.
|
|
175
|
-
* Use with caution
|
|
193
|
+
* Use with caution: bypasses `dataFactory`.
|
|
176
194
|
*/
|
|
177
195
|
getInternalDataStore: () => StoreLike<T | null>;
|
|
178
196
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marianmeres/fetch-store",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/mod.js",
|
|
6
6
|
"types": "dist/mod.d.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"url": "https://github.com/marianmeres/fetch-store/issues"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@marianmeres/pubsub": "^
|
|
18
|
-
"@marianmeres/store": "^
|
|
17
|
+
"@marianmeres/pubsub": "^3.1.0",
|
|
18
|
+
"@marianmeres/store": "^3.0.1"
|
|
19
19
|
}
|
|
20
20
|
}
|