@mmstack/resource 22.1.6 → 22.2.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 +101 -8
- package/fesm2022/mmstack-resource.mjs +499 -380
- package/fesm2022/mmstack-resource.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-resource.d.ts +91 -7
|
@@ -1,10 +1,322 @@
|
|
|
1
|
-
import { HttpHeaders,
|
|
1
|
+
import { HttpHeaders, HttpParams, HttpResponse, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
|
|
2
2
|
import * as i0 from '@angular/core';
|
|
3
|
-
import { isDevMode, signal, computed, untracked, InjectionToken, inject,
|
|
3
|
+
import { isDevMode, signal, computed, untracked, InjectionToken, inject, DestroyRef, PLATFORM_ID, effect, Injector, Injectable, runInInjectionContext, linkedSignal } from '@angular/core';
|
|
4
4
|
import { mutable, toWritable, keepPrevious, sensor, injectTransitionScope, injectPaused, nestedEffect } from '@mmstack/primitives';
|
|
5
5
|
import { finalize, shareReplay, of, tap, map, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
|
|
6
6
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Returns `true` for any object-like value whose own enumerable keys should
|
|
10
|
+
* be sorted for stable hashing. Excludes arrays (positional), `Date`
|
|
11
|
+
* (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
|
|
12
|
+
* (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
|
|
13
|
+
* should be branched on before reaching `hash()`, typically by `hashRequest`).
|
|
14
|
+
*
|
|
15
|
+
* Plain objects, class instances, and `Object.create(null)` all qualify.
|
|
16
|
+
*/
|
|
17
|
+
function isHashableObject(value) {
|
|
18
|
+
if (value === null || typeof value !== 'object')
|
|
19
|
+
return false;
|
|
20
|
+
if (Array.isArray(value))
|
|
21
|
+
return false;
|
|
22
|
+
if (value instanceof Date)
|
|
23
|
+
return false;
|
|
24
|
+
if (value instanceof Map)
|
|
25
|
+
return false;
|
|
26
|
+
if (value instanceof Set)
|
|
27
|
+
return false;
|
|
28
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob)
|
|
29
|
+
return false;
|
|
30
|
+
if (typeof FormData !== 'undefined' && value instanceof FormData)
|
|
31
|
+
return false;
|
|
32
|
+
if (typeof URLSearchParams !== 'undefined' &&
|
|
33
|
+
value instanceof URLSearchParams)
|
|
34
|
+
return false;
|
|
35
|
+
if (value instanceof ArrayBuffer)
|
|
36
|
+
return false;
|
|
37
|
+
if (ArrayBuffer.isView(value))
|
|
38
|
+
return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
function sortKeys(val) {
|
|
42
|
+
return Object.keys(val)
|
|
43
|
+
.toSorted()
|
|
44
|
+
.reduce((result, key) => {
|
|
45
|
+
result[key] = val[key];
|
|
46
|
+
return result;
|
|
47
|
+
}, {});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Internal helper to generate a stable JSON string from an array.
|
|
51
|
+
* - Object-like values (plain, class instances, null-proto) get their own
|
|
52
|
+
* enumerable keys sorted alphabetically.
|
|
53
|
+
* - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
|
|
54
|
+
* - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
|
|
55
|
+
* - Arrays preserve order. `Date` serializes via `toJSON`.
|
|
56
|
+
*
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
59
|
+
function hashKey(queryKey) {
|
|
60
|
+
return JSON.stringify(queryKey, (_, val) => {
|
|
61
|
+
if (val instanceof Map) {
|
|
62
|
+
// Schwartzian: compute each entry's sort key (recursive hash of the
|
|
63
|
+
// Map key) once, then sort by the cheap string compare.
|
|
64
|
+
const entries = [...val.entries()]
|
|
65
|
+
.map((e) => [hash(e[0]), e])
|
|
66
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
67
|
+
.map(([, e]) => e);
|
|
68
|
+
return { __map__: entries };
|
|
69
|
+
}
|
|
70
|
+
if (val instanceof Set) {
|
|
71
|
+
const values = [...val]
|
|
72
|
+
.map((v) => [hash(v), v])
|
|
73
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
74
|
+
.map(([, v]) => v);
|
|
75
|
+
return { __set__: values };
|
|
76
|
+
}
|
|
77
|
+
if (isHashableObject(val))
|
|
78
|
+
return sortKeys(val);
|
|
79
|
+
return val;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Generates a stable, unique string hash from one or more arguments.
|
|
84
|
+
* Useful for creating cache keys or identifiers where object key order shouldn't matter.
|
|
85
|
+
*
|
|
86
|
+
* How it works:
|
|
87
|
+
* - Object-like values (plain objects, class instances, `Object.create(null)`) have
|
|
88
|
+
* their own enumerable keys sorted alphabetically before hashing. This ensures
|
|
89
|
+
* `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
|
|
90
|
+
* - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
|
|
91
|
+
* - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
|
|
92
|
+
*
|
|
93
|
+
* @param {...unknown} args Values to include in the hash.
|
|
94
|
+
* @returns A stable string hash representing the input arguments.
|
|
95
|
+
* @example
|
|
96
|
+
* hash('posts', 10);
|
|
97
|
+
* // => '["posts",10]'
|
|
98
|
+
*
|
|
99
|
+
* hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
|
|
100
|
+
*
|
|
101
|
+
* hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
|
|
102
|
+
*
|
|
103
|
+
* // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
|
|
104
|
+
* // hash('a', undefined, function() {}) => '["a",null,null]'
|
|
105
|
+
*/
|
|
106
|
+
function hash(...args) {
|
|
107
|
+
return hashKey(args);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Top-level field separator for auto-generated cache keys. ASCII Unit Separator
|
|
112
|
+
* (`\x1f`) is deliberately content-rare: it never occurs in HTTP method tokens,
|
|
113
|
+
* URLs, or `encodeURIComponent`/digest output (params, vary headers, body hash),
|
|
114
|
+
* so the structural layout stays unambiguous even when a custom `cache.hash`
|
|
115
|
+
* *prepends* a namespace with ordinary chars (e.g. `tenant:${hashRequest(req)}`).
|
|
116
|
+
* Survives `JSON.stringify` (IndexedDB persistence) and `structuredClone`
|
|
117
|
+
* (cross-tab broadcast) intact.
|
|
118
|
+
*/
|
|
119
|
+
const KEY_DELIMITER = '\x1f';
|
|
120
|
+
/**
|
|
121
|
+
* Recovers the URL portion of an auto-generated cache key — the segment between
|
|
122
|
+
* the 1st and 2nd {@link KEY_DELIMITER} (the key shape is
|
|
123
|
+
* `method␟url␟responseType[␟…]`). Returns `null` when the key has no delimiter
|
|
124
|
+
* (e.g. produced by a custom `hash` that doesn't follow this shape).
|
|
125
|
+
*
|
|
126
|
+
* A namespace prepended with non-delimiter chars collapses into segment 0
|
|
127
|
+
* (`tenant:GET`), so the URL remains segment 1 — method-agnostic and
|
|
128
|
+
* namespacing-tolerant by construction.
|
|
129
|
+
*/
|
|
130
|
+
function extractUrlFromKey(key) {
|
|
131
|
+
const start = key.indexOf(KEY_DELIMITER);
|
|
132
|
+
if (start === -1)
|
|
133
|
+
return null;
|
|
134
|
+
const end = key.indexOf(KEY_DELIMITER, start + 1);
|
|
135
|
+
return end === -1
|
|
136
|
+
? key.slice(start + 1)
|
|
137
|
+
: key.slice(start + 1, end);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* @internal
|
|
141
|
+
* One-way ~64-bit digest from two independent FNV-1a passes. Used for header VALUES in
|
|
142
|
+
* cache keys: keys are persisted (IndexedDB) and broadcast cross-tab, so raw values
|
|
143
|
+
* (auth tokens!) must never appear in them. A single 32-bit digest's 2^-32 collision
|
|
144
|
+
* chance is too thin at a security boundary — two colliding tokens would serve one
|
|
145
|
+
* user's cached data under another user's key; 64 bits puts collisions out of reach.
|
|
146
|
+
* High-entropy secrets are not recoverable from the digest.
|
|
147
|
+
*/
|
|
148
|
+
function digestHeaderValue(value) {
|
|
149
|
+
let h1 = 0x811c9dc5; // FNV-1a offset basis
|
|
150
|
+
let h2 = 0xcbf29ce4; // independent second pass
|
|
151
|
+
for (let i = 0; i < value.length; i++) {
|
|
152
|
+
const c = value.charCodeAt(i);
|
|
153
|
+
h1 = Math.imul(h1 ^ c, 0x01000193); // FNV prime
|
|
154
|
+
h2 = Math.imul(h2 ^ c, 0x01000197); // distinct odd multiplier
|
|
155
|
+
}
|
|
156
|
+
return ((h1 >>> 0).toString(16).padStart(8, '0') +
|
|
157
|
+
(h2 >>> 0).toString(16).padStart(8, '0'));
|
|
158
|
+
}
|
|
159
|
+
function readHeader(headers, name) {
|
|
160
|
+
if (!headers)
|
|
161
|
+
return null;
|
|
162
|
+
if (headers instanceof HttpHeaders) {
|
|
163
|
+
const all = headers.getAll(name);
|
|
164
|
+
return all && all.length ? all.join(',') : null;
|
|
165
|
+
}
|
|
166
|
+
// record form — header names are case-insensitive
|
|
167
|
+
const lower = name.toLowerCase();
|
|
168
|
+
for (const key of Object.keys(headers)) {
|
|
169
|
+
if (key.toLowerCase() !== lower)
|
|
170
|
+
continue;
|
|
171
|
+
const value = headers[key];
|
|
172
|
+
if (value == null)
|
|
173
|
+
return null;
|
|
174
|
+
return Array.isArray(value) ? value.join(',') : String(value);
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Content-negotiation headers whose values are low-entropy and non-identifying —
|
|
180
|
+
* embedded (URI-encoded) raw, keeping keys human-readable and skipping the digest.
|
|
181
|
+
* Anything NOT on this list (Authorization, api keys, tenant/x-* headers — we can't
|
|
182
|
+
* know what they carry) is one-way digested instead.
|
|
183
|
+
*/
|
|
184
|
+
const SAFE_RAW_HEADERS = new Set([
|
|
185
|
+
'accept',
|
|
186
|
+
'accept-language',
|
|
187
|
+
'content-language',
|
|
188
|
+
'content-type',
|
|
189
|
+
]);
|
|
190
|
+
const UNSAFE_HEADER_MESSAGES = new Map([
|
|
191
|
+
[
|
|
192
|
+
'cookie',
|
|
193
|
+
"[@mmstack/resource]: varyHeaders includes 'cookie'. Browser-attached cookies never appear on the request object (so this usually partitions nothing), and manually-set cookie values often rotate per-request (shredding the hit rate). The header IS still honored (digested) — but prefer varying on 'Authorization' or a tenant header.",
|
|
194
|
+
],
|
|
195
|
+
[
|
|
196
|
+
'set-cookie',
|
|
197
|
+
"[@mmstack/resource]: varyHeaders includes 'set-cookie'. Browser-attached cookies never appear on the request object (so this usually partitions nothing), and manually-set cookie values often rotate per-request (shredding the hit rate). The header IS still honored (digested) — but prefer varying on 'Authorization' or a tenant header.",
|
|
198
|
+
],
|
|
199
|
+
[
|
|
200
|
+
'authorization',
|
|
201
|
+
"[@mmstack/resource]: varyHeaders includes 'Authorization'. If your token rotates frequently (e.g., short-lived JWTs), this will cause 100% cache churn on refresh. Consider adding a namespace prefix with the users sub, not using it as a cache-key or using a custom 'cache.hash' function with a stable session/user ID instead.",
|
|
202
|
+
],
|
|
203
|
+
[
|
|
204
|
+
'x-request-id',
|
|
205
|
+
"[@mmstack/resource]: varyHeaders includes 'X-Request-ID'. This header is often set to a unique value per-request, which will cause 100% cache churn. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
206
|
+
],
|
|
207
|
+
[
|
|
208
|
+
'x-correlation-id',
|
|
209
|
+
"[@mmstack/resource]: varyHeaders includes 'X-Correlation-ID'. This header is often set to a unique value per-request, which will cause 100% cache churn. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
210
|
+
],
|
|
211
|
+
[
|
|
212
|
+
'if-none-match',
|
|
213
|
+
"[@mmstack/resource]: varyHeaders includes 'If-None-Match'. This header contains ETags that change whenever the server's resource version changes, which will cause cache misses on every update. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
214
|
+
],
|
|
215
|
+
[
|
|
216
|
+
'if-modified-since',
|
|
217
|
+
"[@mmstack/resource]: varyHeaders includes 'If-Modified-Since'. This header contains timestamps that change whenever the server's resource version changes, which will cause cache misses on every update. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
218
|
+
],
|
|
219
|
+
]);
|
|
220
|
+
function normalizeVaryHeaders(headers, names) {
|
|
221
|
+
const isDev = isDevMode();
|
|
222
|
+
return names
|
|
223
|
+
.map((n) => n.toLowerCase())
|
|
224
|
+
.toSorted()
|
|
225
|
+
.map((name) => {
|
|
226
|
+
if (isDev) {
|
|
227
|
+
const warning = UNSAFE_HEADER_MESSAGES.get(name);
|
|
228
|
+
if (warning)
|
|
229
|
+
console.warn(warning);
|
|
230
|
+
}
|
|
231
|
+
const value = readHeader(headers, name);
|
|
232
|
+
if (value === null)
|
|
233
|
+
return `${name}=`;
|
|
234
|
+
// known-safe values raw (readable, cheap); everything else digested, NEVER raw —
|
|
235
|
+
// keys are persisted to IndexedDB and broadcast across tabs
|
|
236
|
+
return SAFE_RAW_HEADERS.has(name)
|
|
237
|
+
? `${name}=${encodeURIComponent(value)}`
|
|
238
|
+
: `${name}=${digestHeaderValue(value)}`;
|
|
239
|
+
})
|
|
240
|
+
.join('&');
|
|
241
|
+
}
|
|
242
|
+
function normalizeParams(params) {
|
|
243
|
+
const p = params instanceof HttpParams
|
|
244
|
+
? params
|
|
245
|
+
: new HttpParams({ fromObject: params });
|
|
246
|
+
return p
|
|
247
|
+
.keys()
|
|
248
|
+
.toSorted()
|
|
249
|
+
.map((key) => {
|
|
250
|
+
const encodedKey = encodeURIComponent(key);
|
|
251
|
+
return (p.getAll(key) ?? [])
|
|
252
|
+
.map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
|
|
253
|
+
.join('&');
|
|
254
|
+
})
|
|
255
|
+
.join('&');
|
|
256
|
+
}
|
|
257
|
+
function hashBody(body) {
|
|
258
|
+
// File extends Blob — must check File first
|
|
259
|
+
if (typeof File !== 'undefined' && body instanceof File) {
|
|
260
|
+
return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
|
|
261
|
+
}
|
|
262
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
263
|
+
return `Blob:${body.type}:${body.size}`;
|
|
264
|
+
}
|
|
265
|
+
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
266
|
+
const entries = [];
|
|
267
|
+
body.forEach((value, key) => {
|
|
268
|
+
entries.push([key, hashBody(value)]);
|
|
269
|
+
});
|
|
270
|
+
entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
|
|
271
|
+
return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
|
|
272
|
+
}
|
|
273
|
+
if (typeof URLSearchParams !== 'undefined' &&
|
|
274
|
+
body instanceof URLSearchParams) {
|
|
275
|
+
const sp = new URLSearchParams(body);
|
|
276
|
+
sp.sort();
|
|
277
|
+
return `URLSearchParams:${sp.toString()}`;
|
|
278
|
+
}
|
|
279
|
+
if (body instanceof ArrayBuffer) {
|
|
280
|
+
return `ArrayBuffer:${body.byteLength}`;
|
|
281
|
+
}
|
|
282
|
+
if (ArrayBuffer.isView(body)) {
|
|
283
|
+
return `${body.constructor.name}:${body.byteLength}`;
|
|
284
|
+
}
|
|
285
|
+
return hash(body);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Builds a stable cache/dedupe key from an HTTP request shape (accepts both
|
|
289
|
+
* `HttpRequest` and `HttpResourceRequest`).
|
|
290
|
+
*
|
|
291
|
+
* Key composition: `${method}␟${url}␟${responseType}[␟${params}][␟${body}][␟${vary}]`,
|
|
292
|
+
* where `␟` is {@link KEY_DELIMITER} (ASCII Unit Separator) — a content-rare top-level
|
|
293
|
+
* separator. Sub-fields inside `params`/`vary` keep their own `&`/`=` delimiters.
|
|
294
|
+
* - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
|
|
295
|
+
* - Query params are sorted alphabetically and URL-encoded for stability.
|
|
296
|
+
* - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
|
|
297
|
+
* and typed arrays explicitly; everything else flows through key-sorted
|
|
298
|
+
* `JSON.stringify` via `hash()`.
|
|
299
|
+
* - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
|
|
300
|
+
* that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
|
|
301
|
+
* separate entries. Known-safe content-negotiation headers (`Accept`,
|
|
302
|
+
* `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
|
|
303
|
+
* readable keys; all other header VALUES are one-way digested, never embedded raw —
|
|
304
|
+
* keys are persisted to IndexedDB and broadcast across tabs.
|
|
305
|
+
*/
|
|
306
|
+
function hashRequest(req, varyHeaders) {
|
|
307
|
+
const method = req.method ?? 'GET';
|
|
308
|
+
const responseType = req.responseType ?? 'json';
|
|
309
|
+
const base = `${method}${KEY_DELIMITER}${req.url}${KEY_DELIMITER}${responseType}`;
|
|
310
|
+
const params = req.params
|
|
311
|
+
? `${KEY_DELIMITER}${normalizeParams(req.params)}`
|
|
312
|
+
: '';
|
|
313
|
+
const body = req.body != null ? `${KEY_DELIMITER}${hashBody(req.body)}` : '';
|
|
314
|
+
const vary = varyHeaders?.length
|
|
315
|
+
? `${KEY_DELIMITER}vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
|
|
316
|
+
: '';
|
|
317
|
+
return base + params + body + vary;
|
|
318
|
+
}
|
|
319
|
+
|
|
8
320
|
function createNoopDB() {
|
|
9
321
|
return {
|
|
10
322
|
getAll: async () => [],
|
|
@@ -141,6 +453,8 @@ class Cache {
|
|
|
141
453
|
hydrated = false;
|
|
142
454
|
/** Keys invalidated while hydration was still in flight — must not be resurrected by it. */
|
|
143
455
|
hydrationTombstones = new Set();
|
|
456
|
+
/** Dev-only: ensures the "foreign keys, no matcher" hint in invalidateUrlPrefix fires at most once. */
|
|
457
|
+
warnedForeignKeys = false;
|
|
144
458
|
hitCount = signal(0, /* @ts-ignore */
|
|
145
459
|
...(ngDevMode ? [{ debugName: "hitCount" }] : /* istanbul ignore next */ []));
|
|
146
460
|
missCount = signal(0, /* @ts-ignore */
|
|
@@ -187,7 +501,7 @@ class Cache {
|
|
|
187
501
|
};
|
|
188
502
|
if (this.cleanupOpt.maxSize <= 0)
|
|
189
503
|
throw new Error('maxSize must be greater than 0');
|
|
190
|
-
// a non-finite checkInterval disables the sweeper entirely (used by
|
|
504
|
+
// a non-finite checkInterval disables the sweeper entirely (used by provideMockQueryCache)
|
|
191
505
|
const cleanupInterval = Number.isFinite(this.cleanupOpt.checkInterval)
|
|
192
506
|
? setInterval(() => {
|
|
193
507
|
this.cleanup();
|
|
@@ -437,6 +751,55 @@ class Cache {
|
|
|
437
751
|
invalidatePrefix(prefix) {
|
|
438
752
|
return this.invalidateWhere((key) => key.startsWith(prefix));
|
|
439
753
|
}
|
|
754
|
+
/**
|
|
755
|
+
* Invalidates every cache entry whose *request URL* starts with `urlPrefix`,
|
|
756
|
+
* regardless of HTTP method. This is the engine behind `mutationResource`'s
|
|
757
|
+
* `invalidates` option: `'/api/posts'` clears `/api/posts` with any query
|
|
758
|
+
* params, subpaths like `/api/posts/123`, and all `varyHeaders` variants —
|
|
759
|
+
* across GET/HEAD/OPTIONS/POST or any other cached method. Returns the number
|
|
760
|
+
* of entries removed.
|
|
761
|
+
*
|
|
762
|
+
* Unlike {@link invalidatePrefix} (which matches the raw key from its start),
|
|
763
|
+
* this extracts the URL field from the auto-generated key shape, so it is not
|
|
764
|
+
* fooled by the leading method token nor by a namespace a custom `cache.hash`
|
|
765
|
+
* prepends (e.g. `tenant:…`). Plain prefix matching still catches siblings
|
|
766
|
+
* sharing the prefix (`/api/posts-archive`) — pass `'/api/posts/'` to narrow.
|
|
767
|
+
*
|
|
768
|
+
* Keys produced by a custom `hash` that don't follow the auto shape won't be
|
|
769
|
+
* matched by the default; pass `match` to describe how a URL prefix maps onto
|
|
770
|
+
* your key format. In dev mode, if a default-matcher call removes nothing and
|
|
771
|
+
* every cached key is foreign-shaped, this logs a one-time hint pointing at the
|
|
772
|
+
* `match` escape hatch (a likely sign of a custom `hash` with no matcher wired up).
|
|
773
|
+
*
|
|
774
|
+
* @param urlPrefix - URL prefix to match.
|
|
775
|
+
* @param match - Optional custom matcher: given the prefix, returns a key predicate.
|
|
776
|
+
*
|
|
777
|
+
* @example
|
|
778
|
+
* cache.invalidateUrlPrefix('/api/posts');
|
|
779
|
+
* // custom key scheme:
|
|
780
|
+
* cache.invalidateUrlPrefix('/api/posts', (p) => (k) => k.includes(`|url=${p}`));
|
|
781
|
+
*/
|
|
782
|
+
invalidateUrlPrefix(urlPrefix, match) {
|
|
783
|
+
if (match)
|
|
784
|
+
return this.invalidateWhere(match(urlPrefix));
|
|
785
|
+
let sawAutoKey = false;
|
|
786
|
+
const removed = this.invalidateWhere((key) => {
|
|
787
|
+
const url = extractUrlFromKey(key);
|
|
788
|
+
if (url === null)
|
|
789
|
+
return false; // foreign-shaped key
|
|
790
|
+
sawAutoKey = true;
|
|
791
|
+
return url.startsWith(urlPrefix);
|
|
792
|
+
});
|
|
793
|
+
if (isDevMode() &&
|
|
794
|
+
!this.warnedForeignKeys &&
|
|
795
|
+
removed === 0 &&
|
|
796
|
+
!sawAutoKey &&
|
|
797
|
+
untracked(this.internal).size > 0) {
|
|
798
|
+
this.warnedForeignKeys = true;
|
|
799
|
+
console.warn(`[@mmstack/resource] invalidateUrlPrefix('${urlPrefix}') matched nothing, and no cached key follows the default key shape. If you use a custom 'cache.hash', pass an 'invalidateMatcher' (mutationResource) / 'match' (invalidateUrlPrefix) so invalidation can locate your keys.`);
|
|
800
|
+
}
|
|
801
|
+
return removed;
|
|
802
|
+
}
|
|
440
803
|
/**
|
|
441
804
|
* Invalidates every cache entry whose key matches the predicate. Use for
|
|
442
805
|
* arbitrary bulk invalidation that doesn't fit prefix matching (e.g.
|
|
@@ -509,7 +872,48 @@ class Cache {
|
|
|
509
872
|
this.internal.set(new Map(keep));
|
|
510
873
|
}
|
|
511
874
|
}
|
|
512
|
-
const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE'
|
|
875
|
+
const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE', {
|
|
876
|
+
// Memory-only default so a plain queryResource works with zero config. No
|
|
877
|
+
// IndexedDB / BroadcastChannel — keeps it SSR-safe and request-isolated under
|
|
878
|
+
// SSR (root injector is per-request). provideQueryCache() overrides this to
|
|
879
|
+
// layer on persistence / cross-tab sync / global TTL tuning.
|
|
880
|
+
providedIn: 'root',
|
|
881
|
+
factory: () => {
|
|
882
|
+
const cache = new Cache();
|
|
883
|
+
inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
|
|
884
|
+
return cache;
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
/**
|
|
888
|
+
* Provides a deterministic, in-memory `QueryCache` for unit tests.
|
|
889
|
+
*
|
|
890
|
+
* Unlike {@link provideQueryCache} this never touches IndexedDB or BroadcastChannel
|
|
891
|
+
* and disables the cleanup sweep interval (`checkInterval: Infinity`), so it plays
|
|
892
|
+
* nicely with `vi.useFakeTimers()`. It's a real cache (not a stub), so you can
|
|
893
|
+
* assert cache hits via {@link injectQueryCache} / its `stats` signal.
|
|
894
|
+
*
|
|
895
|
+
* @example
|
|
896
|
+
* TestBed.configureTestingModule({
|
|
897
|
+
* providers: [
|
|
898
|
+
* provideMockQueryCache(),
|
|
899
|
+
* provideHttpClient(withInterceptors([createCacheInterceptor()])),
|
|
900
|
+
* ],
|
|
901
|
+
* });
|
|
902
|
+
*/
|
|
903
|
+
function provideMockQueryCache(opt) {
|
|
904
|
+
return {
|
|
905
|
+
provide: CLIENT_CACHE_TOKEN,
|
|
906
|
+
useFactory: () => {
|
|
907
|
+
const cache = new Cache(opt?.ttl, opt?.staleTime, {
|
|
908
|
+
type: 'lru',
|
|
909
|
+
maxSize: 200,
|
|
910
|
+
checkInterval: Infinity,
|
|
911
|
+
});
|
|
912
|
+
inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
|
|
913
|
+
return cache;
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
}
|
|
513
917
|
/**
|
|
514
918
|
* Provides the instance of the QueryCache for queryResource. This should probably be called
|
|
515
919
|
* in your application's root configuration, but can also be overriden with component/module providers.
|
|
@@ -572,399 +976,89 @@ function provideQueryCache(opt) {
|
|
|
572
976
|
return null;
|
|
573
977
|
}
|
|
574
978
|
};
|
|
575
|
-
// version-suffixed so two deploys with incompatible schemas in adjacent tabs don't
|
|
576
|
-
// push entries into each other's caches (the `version` option only fences IndexedDB)
|
|
577
979
|
const syncChannelId = `mmstack-query-cache-sync_v${opt?.version ?? 1}`;
|
|
578
980
|
return {
|
|
579
981
|
provide: CLIENT_CACHE_TOKEN,
|
|
580
982
|
useFactory: () => {
|
|
581
983
|
const onServer = inject(PLATFORM_ID) === 'server';
|
|
582
|
-
// no IndexedDB / BroadcastChannel on the server
|
|
583
|
-
// isolated, request-lived, memory-only cache
|
|
984
|
+
// no IndexedDB / BroadcastChannel on the server
|
|
584
985
|
const syncTabsOpt = !onServer && opt?.syncTabs
|
|
585
986
|
? {
|
|
586
987
|
id: syncChannelId,
|
|
587
988
|
serialize,
|
|
588
|
-
deserialize,
|
|
589
|
-
}
|
|
590
|
-
: undefined;
|
|
591
|
-
const db = onServer || opt?.persist === false
|
|
592
|
-
? undefined
|
|
593
|
-
: createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
|
|
594
|
-
return {
|
|
595
|
-
getAll: () => {
|
|
596
|
-
return db.getAll().then((entries) => {
|
|
597
|
-
return entries
|
|
598
|
-
.map((entry) => {
|
|
599
|
-
const value = deserialize(entry.value);
|
|
600
|
-
if (value === null)
|
|
601
|
-
return null;
|
|
602
|
-
return {
|
|
603
|
-
...entry,
|
|
604
|
-
value,
|
|
605
|
-
};
|
|
606
|
-
})
|
|
607
|
-
.filter((e) => e !== null);
|
|
608
|
-
});
|
|
609
|
-
},
|
|
610
|
-
store: (entry) => {
|
|
611
|
-
return db.store({ ...entry, value: serialize(entry.value) });
|
|
612
|
-
},
|
|
613
|
-
remove: db.remove,
|
|
614
|
-
};
|
|
615
|
-
});
|
|
616
|
-
const cache = new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db);
|
|
617
|
-
// release the sweep interval / channel with the providing injector
|
|
618
|
-
inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
|
|
619
|
-
return cache;
|
|
620
|
-
},
|
|
621
|
-
};
|
|
622
|
-
}
|
|
623
|
-
class NoopCache extends Cache {
|
|
624
|
-
constructor() {
|
|
625
|
-
// Infinity checkInterval → no sweep interval is ever armed, so the shared
|
|
626
|
-
// instance below never pins a timer
|
|
627
|
-
super(undefined, undefined, {
|
|
628
|
-
type: 'lru',
|
|
629
|
-
maxSize: 200,
|
|
630
|
-
checkInterval: Infinity,
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
634
|
-
store(_, __, ___ = super.staleTime, ____ = super.ttl) {
|
|
635
|
-
// noop
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
// one shared instance — minting a NoopCache per injectQueryCache() miss would leak
|
|
639
|
-
// an instance (and previously an interval) on every prod call without a provider
|
|
640
|
-
let NOOP_CACHE;
|
|
641
|
-
/**
|
|
642
|
-
* Injects the `QueryCache` instance that is used within queryResource.
|
|
643
|
-
* Allows for direct modification of cached data, but is mostly meant for internal use.
|
|
644
|
-
*
|
|
645
|
-
* @param injector - (Optional) The injector to use. If not provided, the current
|
|
646
|
-
* injection context is used.
|
|
647
|
-
* @returns The `QueryCache` instance.
|
|
648
|
-
*
|
|
649
|
-
* @example
|
|
650
|
-
* // In your component or service:
|
|
651
|
-
*
|
|
652
|
-
* import { injectQueryCache } from './your-cache';
|
|
653
|
-
*
|
|
654
|
-
* constructor() {
|
|
655
|
-
* const cache = injectQueryCache();
|
|
656
|
-
*
|
|
657
|
-
* const myData = cache.get(() => 'my-data-key');
|
|
658
|
-
* if (myData() !== null) {
|
|
659
|
-
* // ... use cached data ...
|
|
660
|
-
* }
|
|
661
|
-
* }
|
|
662
|
-
*/
|
|
663
|
-
function injectQueryCache(injector) {
|
|
664
|
-
const cache = injector
|
|
665
|
-
? injector.get(CLIENT_CACHE_TOKEN, null, {
|
|
666
|
-
optional: true,
|
|
667
|
-
})
|
|
668
|
-
: inject(CLIENT_CACHE_TOKEN, {
|
|
669
|
-
optional: true,
|
|
670
|
-
});
|
|
671
|
-
if (!cache) {
|
|
672
|
-
if (isDevMode())
|
|
673
|
-
throw new Error('Cache not provided, please add provideQueryCache() to providers array');
|
|
674
|
-
else
|
|
675
|
-
return (NOOP_CACHE ??= new NoopCache());
|
|
676
|
-
}
|
|
677
|
-
return cache;
|
|
678
|
-
}
|
|
679
|
-
/**
|
|
680
|
-
* Injects the cache statistics, including the current size of the cache and the number of hits and misses.
|
|
681
|
-
*
|
|
682
|
-
* @param injector - (Optional) The injector to use. If not provided, the current
|
|
683
|
-
* injection context is used.
|
|
684
|
-
* @returns A signal containing the cache statistics.
|
|
685
|
-
*/
|
|
686
|
-
function injectCacheStats(injector) {
|
|
687
|
-
const cache = injectQueryCache(injector);
|
|
688
|
-
return cache.stats;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
/**
|
|
692
|
-
* Returns `true` for any object-like value whose own enumerable keys should
|
|
693
|
-
* be sorted for stable hashing. Excludes arrays (positional), `Date`
|
|
694
|
-
* (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
|
|
695
|
-
* (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
|
|
696
|
-
* should be branched on before reaching `hash()`, typically by `hashRequest`).
|
|
697
|
-
*
|
|
698
|
-
* Plain objects, class instances, and `Object.create(null)` all qualify.
|
|
699
|
-
*/
|
|
700
|
-
function isHashableObject(value) {
|
|
701
|
-
if (value === null || typeof value !== 'object')
|
|
702
|
-
return false;
|
|
703
|
-
if (Array.isArray(value))
|
|
704
|
-
return false;
|
|
705
|
-
if (value instanceof Date)
|
|
706
|
-
return false;
|
|
707
|
-
if (value instanceof Map)
|
|
708
|
-
return false;
|
|
709
|
-
if (value instanceof Set)
|
|
710
|
-
return false;
|
|
711
|
-
if (typeof Blob !== 'undefined' && value instanceof Blob)
|
|
712
|
-
return false;
|
|
713
|
-
if (typeof FormData !== 'undefined' && value instanceof FormData)
|
|
714
|
-
return false;
|
|
715
|
-
if (typeof URLSearchParams !== 'undefined' &&
|
|
716
|
-
value instanceof URLSearchParams)
|
|
717
|
-
return false;
|
|
718
|
-
if (value instanceof ArrayBuffer)
|
|
719
|
-
return false;
|
|
720
|
-
if (ArrayBuffer.isView(value))
|
|
721
|
-
return false;
|
|
722
|
-
return true;
|
|
723
|
-
}
|
|
724
|
-
function sortKeys(val) {
|
|
725
|
-
return Object.keys(val)
|
|
726
|
-
.toSorted()
|
|
727
|
-
.reduce((result, key) => {
|
|
728
|
-
result[key] = val[key];
|
|
729
|
-
return result;
|
|
730
|
-
}, {});
|
|
731
|
-
}
|
|
732
|
-
/**
|
|
733
|
-
* Internal helper to generate a stable JSON string from an array.
|
|
734
|
-
* - Object-like values (plain, class instances, null-proto) get their own
|
|
735
|
-
* enumerable keys sorted alphabetically.
|
|
736
|
-
* - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
|
|
737
|
-
* - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
|
|
738
|
-
* - Arrays preserve order. `Date` serializes via `toJSON`.
|
|
739
|
-
*
|
|
740
|
-
* @internal
|
|
741
|
-
*/
|
|
742
|
-
function hashKey(queryKey) {
|
|
743
|
-
return JSON.stringify(queryKey, (_, val) => {
|
|
744
|
-
if (val instanceof Map) {
|
|
745
|
-
// Schwartzian: compute each entry's sort key (recursive hash of the
|
|
746
|
-
// Map key) once, then sort by the cheap string compare.
|
|
747
|
-
const entries = [...val.entries()]
|
|
748
|
-
.map((e) => [hash(e[0]), e])
|
|
749
|
-
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
750
|
-
.map(([, e]) => e);
|
|
751
|
-
return { __map__: entries };
|
|
752
|
-
}
|
|
753
|
-
if (val instanceof Set) {
|
|
754
|
-
const values = [...val]
|
|
755
|
-
.map((v) => [hash(v), v])
|
|
756
|
-
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
757
|
-
.map(([, v]) => v);
|
|
758
|
-
return { __set__: values };
|
|
759
|
-
}
|
|
760
|
-
if (isHashableObject(val))
|
|
761
|
-
return sortKeys(val);
|
|
762
|
-
return val;
|
|
763
|
-
});
|
|
989
|
+
deserialize,
|
|
990
|
+
}
|
|
991
|
+
: undefined;
|
|
992
|
+
const db = onServer || opt?.persist === false
|
|
993
|
+
? undefined
|
|
994
|
+
: createSingleStoreDB('mmstack-query-cache-db', (version) => `query-store_v${version}`, opt?.version).then((db) => {
|
|
995
|
+
return {
|
|
996
|
+
getAll: () => {
|
|
997
|
+
return db.getAll().then((entries) => {
|
|
998
|
+
return entries
|
|
999
|
+
.map((entry) => {
|
|
1000
|
+
const value = deserialize(entry.value);
|
|
1001
|
+
if (value === null)
|
|
1002
|
+
return null;
|
|
1003
|
+
return {
|
|
1004
|
+
...entry,
|
|
1005
|
+
value,
|
|
1006
|
+
};
|
|
1007
|
+
})
|
|
1008
|
+
.filter((e) => e !== null);
|
|
1009
|
+
});
|
|
1010
|
+
},
|
|
1011
|
+
store: (entry) => {
|
|
1012
|
+
return db.store({ ...entry, value: serialize(entry.value) });
|
|
1013
|
+
},
|
|
1014
|
+
remove: db.remove,
|
|
1015
|
+
};
|
|
1016
|
+
});
|
|
1017
|
+
const cache = new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt, db);
|
|
1018
|
+
// release the sweep interval / channel with the providing injector
|
|
1019
|
+
inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
|
|
1020
|
+
return cache;
|
|
1021
|
+
},
|
|
1022
|
+
};
|
|
764
1023
|
}
|
|
765
1024
|
/**
|
|
766
|
-
*
|
|
767
|
-
*
|
|
1025
|
+
* Injects the `QueryCache` instance that is used within queryResource.
|
|
1026
|
+
* Allows for direct modification of cached data, but is mostly meant for internal use.
|
|
768
1027
|
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
* `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
|
|
773
|
-
* - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
|
|
774
|
-
* - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
|
|
1028
|
+
* @param injector - (Optional) The injector to use. If not provided, the current
|
|
1029
|
+
* injection context is used.
|
|
1030
|
+
* @returns The `QueryCache` instance.
|
|
775
1031
|
*
|
|
776
|
-
* @param {...unknown} args Values to include in the hash.
|
|
777
|
-
* @returns A stable string hash representing the input arguments.
|
|
778
1032
|
* @example
|
|
779
|
-
*
|
|
780
|
-
* // => '["posts",10]'
|
|
1033
|
+
* // In your component or service:
|
|
781
1034
|
*
|
|
782
|
-
*
|
|
1035
|
+
* import { injectQueryCache } from './your-cache';
|
|
783
1036
|
*
|
|
784
|
-
*
|
|
1037
|
+
* constructor() {
|
|
1038
|
+
* const cache = injectQueryCache();
|
|
785
1039
|
*
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
/**
|
|
794
|
-
* @internal
|
|
795
|
-
* One-way ~64-bit digest from two independent FNV-1a passes. Used for header VALUES in
|
|
796
|
-
* cache keys: keys are persisted (IndexedDB) and broadcast cross-tab, so raw values
|
|
797
|
-
* (auth tokens!) must never appear in them. A single 32-bit digest's 2^-32 collision
|
|
798
|
-
* chance is too thin at a security boundary — two colliding tokens would serve one
|
|
799
|
-
* user's cached data under another user's key; 64 bits puts collisions out of reach.
|
|
800
|
-
* High-entropy secrets are not recoverable from the digest.
|
|
801
|
-
*/
|
|
802
|
-
function digestHeaderValue(value) {
|
|
803
|
-
let h1 = 0x811c9dc5; // FNV-1a offset basis
|
|
804
|
-
let h2 = 0xcbf29ce4; // independent second pass
|
|
805
|
-
for (let i = 0; i < value.length; i++) {
|
|
806
|
-
const c = value.charCodeAt(i);
|
|
807
|
-
h1 = Math.imul(h1 ^ c, 0x01000193); // FNV prime
|
|
808
|
-
h2 = Math.imul(h2 ^ c, 0x01000197); // distinct odd multiplier
|
|
809
|
-
}
|
|
810
|
-
return ((h1 >>> 0).toString(16).padStart(8, '0') +
|
|
811
|
-
(h2 >>> 0).toString(16).padStart(8, '0'));
|
|
812
|
-
}
|
|
813
|
-
function readHeader(headers, name) {
|
|
814
|
-
if (!headers)
|
|
815
|
-
return null;
|
|
816
|
-
if (headers instanceof HttpHeaders) {
|
|
817
|
-
const all = headers.getAll(name);
|
|
818
|
-
return all && all.length ? all.join(',') : null;
|
|
819
|
-
}
|
|
820
|
-
// record form — header names are case-insensitive
|
|
821
|
-
const lower = name.toLowerCase();
|
|
822
|
-
for (const key of Object.keys(headers)) {
|
|
823
|
-
if (key.toLowerCase() !== lower)
|
|
824
|
-
continue;
|
|
825
|
-
const value = headers[key];
|
|
826
|
-
if (value == null)
|
|
827
|
-
return null;
|
|
828
|
-
return Array.isArray(value) ? value.join(',') : String(value);
|
|
829
|
-
}
|
|
830
|
-
return null;
|
|
831
|
-
}
|
|
832
|
-
/**
|
|
833
|
-
* Content-negotiation headers whose values are low-entropy and non-identifying —
|
|
834
|
-
* embedded (URI-encoded) raw, keeping keys human-readable and skipping the digest.
|
|
835
|
-
* Anything NOT on this list (Authorization, api keys, tenant/x-* headers — we can't
|
|
836
|
-
* know what they carry) is one-way digested instead.
|
|
1040
|
+
* const myData = cache.get(() => 'my-data-key');
|
|
1041
|
+
* if (myData() !== null) {
|
|
1042
|
+
* // ... use cached data ...
|
|
1043
|
+
* }
|
|
1044
|
+
* }
|
|
837
1045
|
*/
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
]);
|
|
844
|
-
const UNSAFE_HEADER_MESSAGES = new Map([
|
|
845
|
-
[
|
|
846
|
-
'cookie',
|
|
847
|
-
"[@mmstack/resource]: varyHeaders includes 'cookie'. Browser-attached cookies never appear on the request object (so this usually partitions nothing), and manually-set cookie values often rotate per-request (shredding the hit rate). The header IS still honored (digested) — but prefer varying on 'Authorization' or a tenant header.",
|
|
848
|
-
],
|
|
849
|
-
[
|
|
850
|
-
'set-cookie',
|
|
851
|
-
"[@mmstack/resource]: varyHeaders includes 'set-cookie'. Browser-attached cookies never appear on the request object (so this usually partitions nothing), and manually-set cookie values often rotate per-request (shredding the hit rate). The header IS still honored (digested) — but prefer varying on 'Authorization' or a tenant header.",
|
|
852
|
-
],
|
|
853
|
-
[
|
|
854
|
-
'authorization',
|
|
855
|
-
"[@mmstack/resource]: varyHeaders includes 'Authorization'. If your token rotates frequently (e.g., short-lived JWTs), this will cause 100% cache churn on refresh. Consider adding a namespace prefix with the users sub, not using it as a cache-key or using a custom 'cache.hash' function with a stable session/user ID instead.",
|
|
856
|
-
],
|
|
857
|
-
[
|
|
858
|
-
'x-request-id',
|
|
859
|
-
"[@mmstack/resource]: varyHeaders includes 'X-Request-ID'. This header is often set to a unique value per-request, which will cause 100% cache churn. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
860
|
-
],
|
|
861
|
-
[
|
|
862
|
-
'x-correlation-id',
|
|
863
|
-
"[@mmstack/resource]: varyHeaders includes 'X-Correlation-ID'. This header is often set to a unique value per-request, which will cause 100% cache churn. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
864
|
-
],
|
|
865
|
-
[
|
|
866
|
-
'if-none-match',
|
|
867
|
-
"[@mmstack/resource]: varyHeaders includes 'If-None-Match'. This header contains ETags that change whenever the server's resource version changes, which will cause cache misses on every update. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
868
|
-
],
|
|
869
|
-
[
|
|
870
|
-
'if-modified-since',
|
|
871
|
-
"[@mmstack/resource]: varyHeaders includes 'If-Modified-Since'. This header contains timestamps that change whenever the server's resource version changes, which will cause cache misses on every update. Consider removing it from varyHeaders or using a custom 'cache.hash' function that ignores it.",
|
|
872
|
-
],
|
|
873
|
-
]);
|
|
874
|
-
function normalizeVaryHeaders(headers, names) {
|
|
875
|
-
const isDev = isDevMode();
|
|
876
|
-
return names
|
|
877
|
-
.map((n) => n.toLowerCase())
|
|
878
|
-
.toSorted()
|
|
879
|
-
.map((name) => {
|
|
880
|
-
if (isDev) {
|
|
881
|
-
const warning = UNSAFE_HEADER_MESSAGES.get(name);
|
|
882
|
-
if (warning)
|
|
883
|
-
console.warn(warning);
|
|
884
|
-
}
|
|
885
|
-
const value = readHeader(headers, name);
|
|
886
|
-
if (value === null)
|
|
887
|
-
return `${name}=`;
|
|
888
|
-
// known-safe values raw (readable, cheap); everything else digested, NEVER raw —
|
|
889
|
-
// keys are persisted to IndexedDB and broadcast across tabs
|
|
890
|
-
return SAFE_RAW_HEADERS.has(name)
|
|
891
|
-
? `${name}=${encodeURIComponent(value)}`
|
|
892
|
-
: `${name}=${digestHeaderValue(value)}`;
|
|
893
|
-
})
|
|
894
|
-
.join('&');
|
|
895
|
-
}
|
|
896
|
-
function normalizeParams(params) {
|
|
897
|
-
const p = params instanceof HttpParams
|
|
898
|
-
? params
|
|
899
|
-
: new HttpParams({ fromObject: params });
|
|
900
|
-
return p
|
|
901
|
-
.keys()
|
|
902
|
-
.toSorted()
|
|
903
|
-
.map((key) => {
|
|
904
|
-
const encodedKey = encodeURIComponent(key);
|
|
905
|
-
return (p.getAll(key) ?? [])
|
|
906
|
-
.map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
|
|
907
|
-
.join('&');
|
|
908
|
-
})
|
|
909
|
-
.join('&');
|
|
910
|
-
}
|
|
911
|
-
function hashBody(body) {
|
|
912
|
-
// File extends Blob — must check File first
|
|
913
|
-
if (typeof File !== 'undefined' && body instanceof File) {
|
|
914
|
-
return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
|
|
915
|
-
}
|
|
916
|
-
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
917
|
-
return `Blob:${body.type}:${body.size}`;
|
|
918
|
-
}
|
|
919
|
-
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
920
|
-
const entries = [];
|
|
921
|
-
body.forEach((value, key) => {
|
|
922
|
-
entries.push([key, hashBody(value)]);
|
|
923
|
-
});
|
|
924
|
-
entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
|
|
925
|
-
return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
|
|
926
|
-
}
|
|
927
|
-
if (typeof URLSearchParams !== 'undefined' &&
|
|
928
|
-
body instanceof URLSearchParams) {
|
|
929
|
-
const sp = new URLSearchParams(body);
|
|
930
|
-
sp.sort();
|
|
931
|
-
return `URLSearchParams:${sp.toString()}`;
|
|
932
|
-
}
|
|
933
|
-
if (body instanceof ArrayBuffer) {
|
|
934
|
-
return `ArrayBuffer:${body.byteLength}`;
|
|
935
|
-
}
|
|
936
|
-
if (ArrayBuffer.isView(body)) {
|
|
937
|
-
return `${body.constructor.name}:${body.byteLength}`;
|
|
938
|
-
}
|
|
939
|
-
return hash(body);
|
|
1046
|
+
function injectQueryCache(injector) {
|
|
1047
|
+
const cache = injector
|
|
1048
|
+
? injector.get(CLIENT_CACHE_TOKEN)
|
|
1049
|
+
: inject(CLIENT_CACHE_TOKEN);
|
|
1050
|
+
return cache;
|
|
940
1051
|
}
|
|
941
1052
|
/**
|
|
942
|
-
*
|
|
943
|
-
* `HttpRequest` and `HttpResourceRequest`).
|
|
1053
|
+
* Injects the cache statistics, including the current size of the cache and the number of hits and misses.
|
|
944
1054
|
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
* - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
|
|
949
|
-
* and typed arrays explicitly; everything else flows through key-sorted
|
|
950
|
-
* `JSON.stringify` via `hash()`.
|
|
951
|
-
* - `varyHeaders` (opt-in) mixes the named request headers into the key so responses
|
|
952
|
-
* that differ per header (e.g. `Authorization` → per-user, `Accept-Language`) get
|
|
953
|
-
* separate entries. Known-safe content-negotiation headers (`Accept`,
|
|
954
|
-
* `Accept-Language`, `Content-Language`, `Content-Type`) embed their value raw for
|
|
955
|
-
* readable keys; all other header VALUES are one-way digested, never embedded raw —
|
|
956
|
-
* keys are persisted to IndexedDB and broadcast across tabs.
|
|
1055
|
+
* @param injector - (Optional) The injector to use. If not provided, the current
|
|
1056
|
+
* injection context is used.
|
|
1057
|
+
* @returns A signal containing the cache statistics.
|
|
957
1058
|
*/
|
|
958
|
-
function
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
const base = `${method}:${req.url}:${responseType}`;
|
|
962
|
-
const params = req.params ? `:${normalizeParams(req.params)}` : '';
|
|
963
|
-
const body = req.body != null ? `:${hashBody(req.body)}` : '';
|
|
964
|
-
const vary = varyHeaders?.length
|
|
965
|
-
? `:vary(${normalizeVaryHeaders(req.headers, varyHeaders)})`
|
|
966
|
-
: '';
|
|
967
|
-
return base + params + body + vary;
|
|
1059
|
+
function injectCacheStats(injector) {
|
|
1060
|
+
const cache = injectQueryCache(injector);
|
|
1061
|
+
return cache.stats;
|
|
968
1062
|
}
|
|
969
1063
|
|
|
970
1064
|
/**
|
|
@@ -1868,6 +1962,33 @@ function injectNetworkStatus() {
|
|
|
1868
1962
|
function injectPageVisibility() {
|
|
1869
1963
|
return inject(ResourceSensors).pageVisibility;
|
|
1870
1964
|
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Provides controllable {@link ResourceSensors} for unit tests, letting you drive a
|
|
1967
|
+
* resource's offline / page-hidden behavior deterministically instead of relying on
|
|
1968
|
+
* the real `navigator.onLine` / `document.visibilityState`.
|
|
1969
|
+
*
|
|
1970
|
+
* Pass your own writable signals to toggle state mid-test; omit them for a static
|
|
1971
|
+
* online + visible environment.
|
|
1972
|
+
*
|
|
1973
|
+
* @example
|
|
1974
|
+
* import { signal } from '@angular/core';
|
|
1975
|
+
*
|
|
1976
|
+
* const online = signal(true);
|
|
1977
|
+
* TestBed.configureTestingModule({
|
|
1978
|
+
* providers: [provideMockResourceSensors({ networkStatus: online })],
|
|
1979
|
+
* });
|
|
1980
|
+
* // ...later in the test
|
|
1981
|
+
* online.set(false); // the resource now sees the network as down
|
|
1982
|
+
*/
|
|
1983
|
+
function provideMockResourceSensors(opt) {
|
|
1984
|
+
return {
|
|
1985
|
+
provide: ResourceSensors,
|
|
1986
|
+
useValue: {
|
|
1987
|
+
networkStatus: opt?.networkStatus ?? signal(true),
|
|
1988
|
+
pageVisibility: opt?.pageVisibility ?? signal('visible'),
|
|
1989
|
+
},
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1871
1992
|
|
|
1872
1993
|
function toResourceObject(res) {
|
|
1873
1994
|
return {
|
|
@@ -2418,9 +2539,7 @@ function mutationResource(request, options0 = {}) {
|
|
|
2418
2539
|
circuitBreaker: mergeCircuitBreakerOptions(globalOpts.circuitBreaker, mutOpts.circuitBreaker, options0?.circuitBreaker),
|
|
2419
2540
|
retry: mergeRetryOptions(globalOpts.retry, mutOpts.retry, options0?.retry),
|
|
2420
2541
|
};
|
|
2421
|
-
|
|
2422
|
-
// the only thing registered into the transition scope, not its internal query resource.
|
|
2423
|
-
const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, ...rest } = options;
|
|
2542
|
+
const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, invalidates, invalidateMatcher, ...rest } = options;
|
|
2424
2543
|
const cache = invalidates ? injectQueryCache(options.injector) : undefined;
|
|
2425
2544
|
const requestEqual = equalRequest ?? createEqualRequest(equal);
|
|
2426
2545
|
const triggerOnSame = options.triggerOnSameRequest ?? false;
|
|
@@ -2578,7 +2697,7 @@ function mutationResource(request, options0 = {}) {
|
|
|
2578
2697
|
? invalidates(result.value, (mutation === NULL_VALUE ? undefined : mutation))
|
|
2579
2698
|
: invalidates;
|
|
2580
2699
|
for (const prefix of prefixes)
|
|
2581
|
-
cache.
|
|
2700
|
+
cache.invalidateUrlPrefix(prefix, invalidateMatcher);
|
|
2582
2701
|
}
|
|
2583
2702
|
deferred?.resolve(result.value);
|
|
2584
2703
|
}
|
|
@@ -2643,5 +2762,5 @@ function mutationResource(request, options0 = {}) {
|
|
|
2643
2762
|
* Generated bundle index. Do not edit.
|
|
2644
2763
|
*/
|
|
2645
2764
|
|
|
2646
|
-
export { Cache, MutationCancelledError, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
|
|
2765
|
+
export { Cache, MutationCancelledError, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMockQueryCache, provideMockResourceSensors, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
|
|
2647
2766
|
//# sourceMappingURL=mmstack-resource.mjs.map
|