@mmstack/resource 19.3.1 → 19.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +508 -73
- package/fesm2022/mmstack-resource.mjs +304 -149
- package/fesm2022/mmstack-resource.mjs.map +1 -1
- package/lib/query-resource.d.ts +21 -4
- package/lib/util/cache/cache.d.ts +18 -0
- package/lib/util/circuit-breaker.d.ts +38 -3
- package/lib/util/dedupe-interceptor.d.ts +5 -2
- package/lib/util/hash-request.d.ts +21 -0
- package/lib/util/hash-unknown.d.ts +25 -0
- package/lib/util/index.d.ts +1 -1
- package/lib/util/retry-on-error.d.ts +9 -1
- package/package.json +1 -1
- package/lib/util/url-with-params.d.ts +0 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, linkedSignal, ResourceStatus, Injectable, DestroyRef } from '@angular/core';
|
|
3
3
|
import { mutable, toWritable, sensor, nestedEffect } from '@mmstack/primitives';
|
|
4
|
-
import { HttpHeaders, HttpResponse, HttpContextToken, HttpContext,
|
|
4
|
+
import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
|
|
5
5
|
import { of, tap, map, finalize, shareReplay, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
|
|
6
6
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
|
7
7
|
|
|
@@ -193,6 +193,12 @@ class Cache {
|
|
|
193
193
|
const value = syncTabs.deserialize(msg.entry.value);
|
|
194
194
|
if (value === null)
|
|
195
195
|
return;
|
|
196
|
+
// Last-write-wins by `updated` timestamp. If our local entry was
|
|
197
|
+
// written more recently than the broadcast we just received, the
|
|
198
|
+
// broadcast is stale (in-flight when we wrote locally) — drop it.
|
|
199
|
+
const existing = untracked(this.internal).get(msg.entry.key);
|
|
200
|
+
if (existing && existing.updated >= msg.entry.updated)
|
|
201
|
+
return;
|
|
196
202
|
this.storeInternal(msg.entry.key, value, msg.entry.stale - msg.entry.updated, msg.entry.expiresAt - msg.entry.updated, true, false);
|
|
197
203
|
}
|
|
198
204
|
else if (msg.action === 'invalidate') {
|
|
@@ -340,6 +346,31 @@ class Cache {
|
|
|
340
346
|
invalidate(key) {
|
|
341
347
|
this.invalidateInternal(key);
|
|
342
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Invalidates every cache entry whose key starts with `prefix`. Common after a
|
|
351
|
+
* list-mutating operation (e.g. invalidate every paginated `GET /api/posts*`
|
|
352
|
+
* after a POST). Returns the number of entries removed.
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* cache.invalidatePrefix('GET https://api.example.com/posts');
|
|
356
|
+
*/
|
|
357
|
+
invalidatePrefix(prefix) {
|
|
358
|
+
return this.invalidateWhere((key) => key.startsWith(prefix));
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Invalidates every cache entry whose key matches the predicate. Use for
|
|
362
|
+
* arbitrary bulk invalidation that doesn't fit prefix matching (e.g.
|
|
363
|
+
* "everything containing `userId=42`"). Returns the number of entries removed.
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* cache.invalidateWhere((key) => key.includes('/me/'));
|
|
367
|
+
*/
|
|
368
|
+
invalidateWhere(predicate) {
|
|
369
|
+
const keys = Array.from(untracked(this.internal).keys()).filter(predicate);
|
|
370
|
+
for (const key of keys)
|
|
371
|
+
this.invalidateInternal(key);
|
|
372
|
+
return keys.length;
|
|
373
|
+
}
|
|
343
374
|
invalidateInternal(key, fromSync = false) {
|
|
344
375
|
const entry = this.getUntracked(key);
|
|
345
376
|
if (!entry)
|
|
@@ -519,6 +550,173 @@ function injectQueryCache(injector) {
|
|
|
519
550
|
return cache;
|
|
520
551
|
}
|
|
521
552
|
|
|
553
|
+
/**
|
|
554
|
+
* Returns `true` for any object-like value whose own enumerable keys should
|
|
555
|
+
* be sorted for stable hashing. Excludes arrays (positional), `Date`
|
|
556
|
+
* (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
|
|
557
|
+
* (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
|
|
558
|
+
* should be branched on before reaching `hash()`, typically by `hashRequest`).
|
|
559
|
+
*
|
|
560
|
+
* Plain objects, class instances, and `Object.create(null)` all qualify.
|
|
561
|
+
*/
|
|
562
|
+
function isHashableObject(value) {
|
|
563
|
+
if (value === null || typeof value !== 'object')
|
|
564
|
+
return false;
|
|
565
|
+
if (Array.isArray(value))
|
|
566
|
+
return false;
|
|
567
|
+
if (value instanceof Date)
|
|
568
|
+
return false;
|
|
569
|
+
if (value instanceof Map)
|
|
570
|
+
return false;
|
|
571
|
+
if (value instanceof Set)
|
|
572
|
+
return false;
|
|
573
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob)
|
|
574
|
+
return false;
|
|
575
|
+
if (typeof FormData !== 'undefined' && value instanceof FormData)
|
|
576
|
+
return false;
|
|
577
|
+
if (typeof URLSearchParams !== 'undefined' &&
|
|
578
|
+
value instanceof URLSearchParams)
|
|
579
|
+
return false;
|
|
580
|
+
if (value instanceof ArrayBuffer)
|
|
581
|
+
return false;
|
|
582
|
+
if (ArrayBuffer.isView(value))
|
|
583
|
+
return false;
|
|
584
|
+
return true;
|
|
585
|
+
}
|
|
586
|
+
function sortKeys(val) {
|
|
587
|
+
return Object.keys(val)
|
|
588
|
+
.toSorted()
|
|
589
|
+
.reduce((result, key) => {
|
|
590
|
+
result[key] = val[key];
|
|
591
|
+
return result;
|
|
592
|
+
}, {});
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Internal helper to generate a stable JSON string from an array.
|
|
596
|
+
* - Object-like values (plain, class instances, null-proto) get their own
|
|
597
|
+
* enumerable keys sorted alphabetically.
|
|
598
|
+
* - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
|
|
599
|
+
* - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
|
|
600
|
+
* - Arrays preserve order. `Date` serializes via `toJSON`.
|
|
601
|
+
*
|
|
602
|
+
* @internal
|
|
603
|
+
*/
|
|
604
|
+
function hashKey(queryKey) {
|
|
605
|
+
return JSON.stringify(queryKey, (_, val) => {
|
|
606
|
+
if (val instanceof Map) {
|
|
607
|
+
// Schwartzian: compute each entry's sort key (recursive hash of the
|
|
608
|
+
// Map key) once, then sort by the cheap string compare.
|
|
609
|
+
const entries = [...val.entries()]
|
|
610
|
+
.map((e) => [hash(e[0]), e])
|
|
611
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
612
|
+
.map(([, e]) => e);
|
|
613
|
+
return { __map__: entries };
|
|
614
|
+
}
|
|
615
|
+
if (val instanceof Set) {
|
|
616
|
+
const values = [...val]
|
|
617
|
+
.map((v) => [hash(v), v])
|
|
618
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
619
|
+
.map(([, v]) => v);
|
|
620
|
+
return { __set__: values };
|
|
621
|
+
}
|
|
622
|
+
if (isHashableObject(val))
|
|
623
|
+
return sortKeys(val);
|
|
624
|
+
return val;
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Generates a stable, unique string hash from one or more arguments.
|
|
629
|
+
* Useful for creating cache keys or identifiers where object key order shouldn't matter.
|
|
630
|
+
*
|
|
631
|
+
* How it works:
|
|
632
|
+
* - Object-like values (plain objects, class instances, `Object.create(null)`) have
|
|
633
|
+
* their own enumerable keys sorted alphabetically before hashing. This ensures
|
|
634
|
+
* `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
|
|
635
|
+
* - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
|
|
636
|
+
* - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
|
|
637
|
+
*
|
|
638
|
+
* @param {...unknown} args Values to include in the hash.
|
|
639
|
+
* @returns A stable string hash representing the input arguments.
|
|
640
|
+
* @example
|
|
641
|
+
* hash('posts', 10);
|
|
642
|
+
* // => '["posts",10]'
|
|
643
|
+
*
|
|
644
|
+
* hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
|
|
645
|
+
*
|
|
646
|
+
* hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
|
|
647
|
+
*
|
|
648
|
+
* // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
|
|
649
|
+
* // hash('a', undefined, function() {}) => '["a",null,null]'
|
|
650
|
+
*/
|
|
651
|
+
function hash(...args) {
|
|
652
|
+
return hashKey(args);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function normalizeParams(params) {
|
|
656
|
+
const p = params instanceof HttpParams
|
|
657
|
+
? params
|
|
658
|
+
: new HttpParams({ fromObject: params });
|
|
659
|
+
return p
|
|
660
|
+
.keys()
|
|
661
|
+
.toSorted()
|
|
662
|
+
.map((key) => {
|
|
663
|
+
const encodedKey = encodeURIComponent(key);
|
|
664
|
+
return (p.getAll(key) ?? [])
|
|
665
|
+
.map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
|
|
666
|
+
.join('&');
|
|
667
|
+
})
|
|
668
|
+
.join('&');
|
|
669
|
+
}
|
|
670
|
+
function hashBody(body) {
|
|
671
|
+
// File extends Blob — must check File first
|
|
672
|
+
if (typeof File !== 'undefined' && body instanceof File) {
|
|
673
|
+
return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
|
|
674
|
+
}
|
|
675
|
+
if (typeof Blob !== 'undefined' && body instanceof Blob) {
|
|
676
|
+
return `Blob:${body.type}:${body.size}`;
|
|
677
|
+
}
|
|
678
|
+
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
679
|
+
const entries = [];
|
|
680
|
+
body.forEach((value, key) => {
|
|
681
|
+
entries.push([key, hashBody(value)]);
|
|
682
|
+
});
|
|
683
|
+
entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
|
|
684
|
+
return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
|
|
685
|
+
}
|
|
686
|
+
if (typeof URLSearchParams !== 'undefined' &&
|
|
687
|
+
body instanceof URLSearchParams) {
|
|
688
|
+
const sp = new URLSearchParams(body);
|
|
689
|
+
sp.sort();
|
|
690
|
+
return `URLSearchParams:${sp.toString()}`;
|
|
691
|
+
}
|
|
692
|
+
if (body instanceof ArrayBuffer) {
|
|
693
|
+
return `ArrayBuffer:${body.byteLength}`;
|
|
694
|
+
}
|
|
695
|
+
if (ArrayBuffer.isView(body)) {
|
|
696
|
+
return `${body.constructor.name}:${body.byteLength}`;
|
|
697
|
+
}
|
|
698
|
+
return hash(body);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Builds a stable cache/dedupe key from an HTTP request shape (accepts both
|
|
702
|
+
* `HttpRequest` and `HttpResourceRequest`).
|
|
703
|
+
*
|
|
704
|
+
* Key composition: `${method}:${url}:${responseType}[:${params}][:${body}]`
|
|
705
|
+
* - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
|
|
706
|
+
* - Query params are sorted alphabetically and URL-encoded for stability.
|
|
707
|
+
* - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
|
|
708
|
+
* and typed arrays explicitly; everything else flows through key-sorted
|
|
709
|
+
* `JSON.stringify` via `hash()`.
|
|
710
|
+
*/
|
|
711
|
+
function hashRequest(req) {
|
|
712
|
+
const method = req.method ?? 'GET';
|
|
713
|
+
const responseType = req.responseType ?? 'json';
|
|
714
|
+
const base = `${method}:${req.url}:${responseType}`;
|
|
715
|
+
const params = req.params ? `:${normalizeParams(req.params)}` : '';
|
|
716
|
+
const body = req.body != null ? `:${hashBody(req.body)}` : '';
|
|
717
|
+
return base + params + body;
|
|
718
|
+
}
|
|
719
|
+
|
|
522
720
|
const CACHE_CONTEXT = new HttpContextToken(() => ({
|
|
523
721
|
cache: false,
|
|
524
722
|
}));
|
|
@@ -662,7 +860,7 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
|
|
|
662
860
|
const opt = getCacheContext(req.context);
|
|
663
861
|
if (!opt.cache)
|
|
664
862
|
return next(req);
|
|
665
|
-
const key = opt.key ?? req
|
|
863
|
+
const key = opt.key ?? hashRequest(req);
|
|
666
864
|
const entry = cache.getUntracked(key); // null if expired or not found
|
|
667
865
|
// If the entry is not stale, return it
|
|
668
866
|
if (entry && !entry.isStale)
|
|
@@ -682,7 +880,9 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
|
|
|
682
880
|
});
|
|
683
881
|
}
|
|
684
882
|
return next(req).pipe(tap((event) => {
|
|
685
|
-
if (event instanceof HttpResponse
|
|
883
|
+
if (!(event instanceof HttpResponse))
|
|
884
|
+
return;
|
|
885
|
+
if (event.ok) {
|
|
686
886
|
const cacheControl = parseCacheControlHeader(event);
|
|
687
887
|
if (cacheControl.noStore && !opt.ignoreCacheControl)
|
|
688
888
|
return;
|
|
@@ -701,6 +901,17 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
|
|
|
701
901
|
})
|
|
702
902
|
: event;
|
|
703
903
|
cache.store(key, parsedResponse, staleTime, ttl, opt.persist);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
// 304 → server confirmed our cached entry is still valid. Re-stamp the
|
|
907
|
+
// existing entry so subsequent reads within the new freshness window
|
|
908
|
+
// don't trigger another revalidation round-trip.
|
|
909
|
+
if (event.status === 304 && entry) {
|
|
910
|
+
const cacheControl = parseCacheControlHeader(event);
|
|
911
|
+
const { staleTime, ttl } = opt.ignoreCacheControl
|
|
912
|
+
? opt
|
|
913
|
+
: resolveTimings(cacheControl, opt.staleTime, opt.ttl);
|
|
914
|
+
cache.store(key, entry.value, staleTime, ttl, opt.persist);
|
|
704
915
|
}
|
|
705
916
|
}), map((event) => {
|
|
706
917
|
// handle 304 responses due to eTag/last-modified
|
|
@@ -728,17 +939,18 @@ function catchValueError(resource, fallback) {
|
|
|
728
939
|
|
|
729
940
|
/** @internal */
|
|
730
941
|
const DEFAULT_OPTIONS = {
|
|
731
|
-
|
|
942
|
+
threshold: 5,
|
|
732
943
|
timeout: 30000,
|
|
733
944
|
shouldFail: () => true,
|
|
734
945
|
shouldFailForever: () => false,
|
|
735
946
|
};
|
|
736
947
|
/** @internal */
|
|
737
|
-
function internalCeateCircuitBreaker(
|
|
948
|
+
function internalCeateCircuitBreaker(threshold = 5, resetTimeout = 30000, shouldFail = () => true, shouldFailForever = () => false) {
|
|
738
949
|
const halfOpen = signal(false);
|
|
739
950
|
const failureCount = signal(0);
|
|
951
|
+
const failedForever = signal(false);
|
|
740
952
|
const status = computed(() => {
|
|
741
|
-
if (failureCount() >=
|
|
953
|
+
if (failedForever() || failureCount() >= threshold)
|
|
742
954
|
return 'OPEN';
|
|
743
955
|
return halfOpen() ? 'HALF_OPEN' : 'CLOSED';
|
|
744
956
|
});
|
|
@@ -752,17 +964,17 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
|
|
|
752
964
|
if (!untracked(isOpen))
|
|
753
965
|
return;
|
|
754
966
|
halfOpen.set(true);
|
|
755
|
-
failureCount.set(
|
|
967
|
+
failureCount.set(threshold - 1);
|
|
756
968
|
};
|
|
757
|
-
|
|
969
|
+
// Auto-probe effect: schedules a half-open retry after `resetTimeout` whenever
|
|
970
|
+
// the breaker is open, *unless* we've been failed forever (in which case only
|
|
971
|
+
// hardReset() can recover).
|
|
758
972
|
const effectRef = effect((cleanup) => {
|
|
759
|
-
if (!isOpen())
|
|
973
|
+
if (!isOpen() || failedForever())
|
|
760
974
|
return;
|
|
761
975
|
const timeout = setTimeout(tryOnce, resetTimeout);
|
|
762
|
-
failForeverResetId = timeout;
|
|
763
976
|
return cleanup(() => {
|
|
764
977
|
clearTimeout(timeout);
|
|
765
|
-
failForeverResetId = null;
|
|
766
978
|
});
|
|
767
979
|
});
|
|
768
980
|
const failInternal = () => {
|
|
@@ -770,12 +982,8 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
|
|
|
770
982
|
halfOpen.set(false);
|
|
771
983
|
};
|
|
772
984
|
const failForever = () => {
|
|
773
|
-
|
|
774
|
-
clearTimeout(failForeverResetId);
|
|
775
|
-
effectRef.destroy();
|
|
776
|
-
failureCount.set(Infinity);
|
|
985
|
+
failedForever.set(true);
|
|
777
986
|
halfOpen.set(false);
|
|
778
|
-
return;
|
|
779
987
|
};
|
|
780
988
|
const fail = (err) => {
|
|
781
989
|
if (shouldFailForever(err))
|
|
@@ -784,6 +992,11 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
|
|
|
784
992
|
return failInternal();
|
|
785
993
|
// If the error does not trigger a failure, we do nothing.
|
|
786
994
|
};
|
|
995
|
+
const hardReset = () => {
|
|
996
|
+
failedForever.set(false);
|
|
997
|
+
failureCount.set(0);
|
|
998
|
+
halfOpen.set(false);
|
|
999
|
+
};
|
|
787
1000
|
return {
|
|
788
1001
|
status,
|
|
789
1002
|
isClosed,
|
|
@@ -791,6 +1004,7 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
|
|
|
791
1004
|
fail,
|
|
792
1005
|
success,
|
|
793
1006
|
halfOpen: tryOnce,
|
|
1007
|
+
hardReset,
|
|
794
1008
|
destroy: () => effectRef.destroy(),
|
|
795
1009
|
};
|
|
796
1010
|
}
|
|
@@ -809,18 +1023,43 @@ function createNeverBrokenCircuitBreaker() {
|
|
|
809
1023
|
halfOpen: () => {
|
|
810
1024
|
// noop
|
|
811
1025
|
},
|
|
1026
|
+
hardReset: () => {
|
|
1027
|
+
// noop
|
|
1028
|
+
},
|
|
812
1029
|
destroy: () => {
|
|
813
1030
|
// noop
|
|
814
1031
|
},
|
|
815
1032
|
};
|
|
816
1033
|
}
|
|
817
1034
|
const CB_DEFAULT_OPTIONS = new InjectionToken('MMSTACK_CIRCUIT_BREAKER_DEFAULT_OPTIONS');
|
|
1035
|
+
/**
|
|
1036
|
+
* Provides application-wide default options for {@link createCircuitBreaker}.
|
|
1037
|
+
* Any `createCircuitBreaker()` call without explicit options (or with only
|
|
1038
|
+
* partial options) merges these defaults in, so you can centralize threshold /
|
|
1039
|
+
* timeout / failure-classifier behavior in one place.
|
|
1040
|
+
*
|
|
1041
|
+
* Per-call options always win over the provided defaults.
|
|
1042
|
+
*
|
|
1043
|
+
* @example
|
|
1044
|
+
* ```ts
|
|
1045
|
+
* bootstrapApplication(AppComponent, {
|
|
1046
|
+
* providers: [
|
|
1047
|
+
* provideCircuitBreakerDefaultOptions({
|
|
1048
|
+
* threshold: 10,
|
|
1049
|
+
* timeout: 60_000,
|
|
1050
|
+
* shouldFailForever: (err) =>
|
|
1051
|
+
* err instanceof HttpErrorResponse && [401, 403].includes(err.status),
|
|
1052
|
+
* }),
|
|
1053
|
+
* ],
|
|
1054
|
+
* });
|
|
1055
|
+
* ```
|
|
1056
|
+
*/
|
|
818
1057
|
function provideCircuitBreakerDefaultOptions(options) {
|
|
819
1058
|
return {
|
|
820
1059
|
provide: CB_DEFAULT_OPTIONS,
|
|
821
1060
|
useValue: {
|
|
822
1061
|
...DEFAULT_OPTIONS,
|
|
823
|
-
...options,
|
|
1062
|
+
...normalizeThreshold(options),
|
|
824
1063
|
},
|
|
825
1064
|
};
|
|
826
1065
|
}
|
|
@@ -829,12 +1068,23 @@ function injectCircuitBreakerOptions(injector = inject(Injector)) {
|
|
|
829
1068
|
optional: true,
|
|
830
1069
|
});
|
|
831
1070
|
}
|
|
1071
|
+
/** @internal — strips the deprecated `treshold` field and folds it into `threshold` */
|
|
1072
|
+
function normalizeThreshold(opt) {
|
|
1073
|
+
if (!opt || typeof opt !== 'object' || 'isClosed' in opt)
|
|
1074
|
+
return {};
|
|
1075
|
+
const { treshold, threshold, ...rest } = opt;
|
|
1076
|
+
return {
|
|
1077
|
+
...rest,
|
|
1078
|
+
threshold: threshold ?? treshold,
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
832
1081
|
/**
|
|
833
1082
|
* Creates a circuit breaker instance.
|
|
834
1083
|
*
|
|
835
1084
|
* @param options - Configuration options for the circuit breaker. Can be:
|
|
836
|
-
* - `undefined`:
|
|
837
|
-
* - `
|
|
1085
|
+
* - `undefined`: Uses defaults (threshold: 5, timeout: 30000ms) or provided defaults via {@link provideCircuitBreakerDefaultOptions}.
|
|
1086
|
+
* - `false`: Creates a "no-op" circuit breaker that is always closed (never trips).
|
|
1087
|
+
* - `true`: Creates a circuit breaker with default settings.
|
|
838
1088
|
* - `CircuitBreaker`: Reuses an existing `CircuitBreaker` instance.
|
|
839
1089
|
* - `{ threshold?: number; timeout?: number; }`: Creates a circuit breaker with the specified threshold and timeout.
|
|
840
1090
|
*
|
|
@@ -857,11 +1107,11 @@ function createCircuitBreaker(opt, injector) {
|
|
|
857
1107
|
return createNeverBrokenCircuitBreaker();
|
|
858
1108
|
if (typeof opt === 'object' && 'isClosed' in opt)
|
|
859
1109
|
return opt;
|
|
860
|
-
const {
|
|
1110
|
+
const { threshold, timeout, shouldFail, shouldFailForever } = {
|
|
861
1111
|
...injectCircuitBreakerOptions(injector),
|
|
862
|
-
...opt,
|
|
1112
|
+
...normalizeThreshold(opt),
|
|
863
1113
|
};
|
|
864
|
-
return internalCeateCircuitBreaker(
|
|
1114
|
+
return internalCeateCircuitBreaker(threshold, timeout, shouldFail, shouldFailForever);
|
|
865
1115
|
}
|
|
866
1116
|
|
|
867
1117
|
// Heavily inspired by: https://dev.to/kasual1/request-deduplication-in-angular-3pd8
|
|
@@ -893,6 +1143,9 @@ function noDedupe(ctx = new HttpContext()) {
|
|
|
893
1143
|
*
|
|
894
1144
|
* @param allowed - An array of HTTP methods for which deduplication should be enabled.
|
|
895
1145
|
* Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
|
|
1146
|
+
* @param keyFn - Optional function to compute the dedupe key from a request.
|
|
1147
|
+
* Defaults to `hashRequest`, which includes method, URL,
|
|
1148
|
+
* response type, params, and body.
|
|
896
1149
|
*
|
|
897
1150
|
* @returns An `HttpInterceptorFn` that implements the request deduplication logic.
|
|
898
1151
|
*
|
|
@@ -916,97 +1169,22 @@ function noDedupe(ctx = new HttpContext()) {
|
|
|
916
1169
|
* ],
|
|
917
1170
|
* };
|
|
918
1171
|
*/
|
|
919
|
-
function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OPTIONS']) {
|
|
1172
|
+
function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OPTIONS'], keyFn = hashRequest) {
|
|
920
1173
|
const inFlight = new Map();
|
|
921
1174
|
const DEDUPE_METHODS = new Set(allowed);
|
|
922
1175
|
return (req, next) => {
|
|
923
1176
|
if (!DEDUPE_METHODS.has(req.method) || req.context.get(NO_DEDUPE))
|
|
924
1177
|
return next(req);
|
|
925
|
-
const
|
|
1178
|
+
const key = keyFn(req);
|
|
1179
|
+
const found = inFlight.get(key);
|
|
926
1180
|
if (found)
|
|
927
1181
|
return found;
|
|
928
|
-
const request = next(req).pipe(finalize(() => inFlight.delete(
|
|
929
|
-
inFlight.set(
|
|
1182
|
+
const request = next(req).pipe(finalize(() => inFlight.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
|
|
1183
|
+
inFlight.set(key, request);
|
|
930
1184
|
return request;
|
|
931
1185
|
};
|
|
932
1186
|
}
|
|
933
1187
|
|
|
934
|
-
/**
|
|
935
|
-
* Checks if `value` is a plain JavaScript object (e.g., `{}` or `new Object()`).
|
|
936
|
-
* Distinguishes from arrays, null, and class instances. Acts as a type predicate,
|
|
937
|
-
* narrowing `value` to `UnknownObject` if `true`.
|
|
938
|
-
*
|
|
939
|
-
* @param value The value to check.
|
|
940
|
-
* @returns {value is UnknownObject} `true` if `value` is a plain object, otherwise `false`.
|
|
941
|
-
* @example
|
|
942
|
-
* isPlainObject({}) // => true
|
|
943
|
-
* isPlainObject([]) // => false
|
|
944
|
-
* isPlainObject(null) // => false
|
|
945
|
-
* isPlainObject(new Date()) // => false
|
|
946
|
-
*/
|
|
947
|
-
function isPlainObject(value) {
|
|
948
|
-
if (value === null || typeof value !== 'object')
|
|
949
|
-
return false;
|
|
950
|
-
const proto = Object.getPrototypeOf(value);
|
|
951
|
-
if (proto === null)
|
|
952
|
-
return false; // remove Object.create(null);
|
|
953
|
-
return proto === Object.prototype;
|
|
954
|
-
}
|
|
955
|
-
/**
|
|
956
|
-
* Internal helper to generate a stable JSON string from an array.
|
|
957
|
-
* Sorts keys of plain objects within the array alphabetically before serialization
|
|
958
|
-
* to ensure hash stability regardless of key order.
|
|
959
|
-
*
|
|
960
|
-
* @param queryKey The array of values to serialize.
|
|
961
|
-
* @returns A stable JSON string representation.
|
|
962
|
-
* @internal
|
|
963
|
-
*/
|
|
964
|
-
function hashKey(queryKey) {
|
|
965
|
-
return JSON.stringify(queryKey, (_, val) => isPlainObject(val)
|
|
966
|
-
? Object.keys(val)
|
|
967
|
-
.toSorted()
|
|
968
|
-
.reduce((result, key) => {
|
|
969
|
-
result[key] = val[key];
|
|
970
|
-
return result;
|
|
971
|
-
}, {})
|
|
972
|
-
: val);
|
|
973
|
-
}
|
|
974
|
-
/**
|
|
975
|
-
* Generates a stable, unique string hash from one or more arguments.
|
|
976
|
-
* Useful for creating cache keys or identifiers where object key order shouldn't matter.
|
|
977
|
-
*
|
|
978
|
-
* How it works:
|
|
979
|
-
* - Plain objects within the arguments have their keys sorted alphabetically before hashing.
|
|
980
|
-
* This ensures that `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
|
|
981
|
-
* - Uses `JSON.stringify` internally with custom sorting for plain objects via `hashKey`.
|
|
982
|
-
* - Non-plain objects (arrays, Dates, etc.) and primitives are serialized naturally.
|
|
983
|
-
*
|
|
984
|
-
* @param {...unknown} args Values to include in the hash.
|
|
985
|
-
* @returns A stable string hash representing the input arguments.
|
|
986
|
-
* @example
|
|
987
|
-
* const userQuery = (id: number) => ['user', { id, timestamp: Date.now() }];
|
|
988
|
-
*
|
|
989
|
-
* const obj1 = { a: 1, b: 2 };
|
|
990
|
-
* const obj2 = { b: 2, a: 1 }; // Same keys/values, different order
|
|
991
|
-
*
|
|
992
|
-
* hash('posts', 10);
|
|
993
|
-
* // => '["posts",10]'
|
|
994
|
-
*
|
|
995
|
-
* hash('config', obj1);
|
|
996
|
-
* // => '["config",{"a":1,"b":2}]'
|
|
997
|
-
*
|
|
998
|
-
* hash('config', obj2);
|
|
999
|
-
* // => '["config",{"a":1,"b":2}]' (Same as above due to key sorting)
|
|
1000
|
-
*
|
|
1001
|
-
* hash(['todos', { status: 'done', owner: obj1 }]);
|
|
1002
|
-
* // => '[["todos",{"owner":{"a":1,"b":2},"status":"done"}]]'
|
|
1003
|
-
*
|
|
1004
|
-
* // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
|
|
1005
|
-
* // hash('a', undefined, function() {}) => '["a",null,null]'
|
|
1006
|
-
*/
|
|
1007
|
-
function hash(...args) {
|
|
1008
|
-
return hashKey(args);
|
|
1009
|
-
}
|
|
1010
1188
|
function equalTransferCache(a, b) {
|
|
1011
1189
|
if (!a && !b)
|
|
1012
1190
|
return true;
|
|
@@ -1234,13 +1412,16 @@ function refresh(resource, destroyRef, refresh) {
|
|
|
1234
1412
|
}
|
|
1235
1413
|
|
|
1236
1414
|
// Retry on error, if number is provided it will retry that many times with exponential backoff, otherwise it will use the options provided
|
|
1237
|
-
function retryOnError(res, opt) {
|
|
1415
|
+
function retryOnError(res, opt, onError) {
|
|
1238
1416
|
const max = opt ? (typeof opt === 'number' ? opt : (opt.max ?? 0)) : 0;
|
|
1239
1417
|
const backoff = typeof opt === 'object' ? (opt.backoff ?? 1000) : 1000;
|
|
1240
1418
|
let retries = 0;
|
|
1241
1419
|
let timeout;
|
|
1242
|
-
const
|
|
1243
|
-
|
|
1420
|
+
const handleError = () => {
|
|
1421
|
+
const err = untracked(res.error);
|
|
1422
|
+
const isFinal = retries >= max;
|
|
1423
|
+
onError?.(err, retries, isFinal);
|
|
1424
|
+
if (isFinal)
|
|
1244
1425
|
return;
|
|
1245
1426
|
retries++;
|
|
1246
1427
|
if (timeout)
|
|
@@ -1255,7 +1436,7 @@ function retryOnError(res, opt) {
|
|
|
1255
1436
|
const ref = effect(() => {
|
|
1256
1437
|
switch (res.status()) {
|
|
1257
1438
|
case ResourceStatus.Error:
|
|
1258
|
-
return
|
|
1439
|
+
return handleError();
|
|
1259
1440
|
case ResourceStatus.Resolved:
|
|
1260
1441
|
return onSuccess();
|
|
1261
1442
|
}
|
|
@@ -1302,29 +1483,6 @@ function toResourceObject(res) {
|
|
|
1302
1483
|
};
|
|
1303
1484
|
}
|
|
1304
1485
|
|
|
1305
|
-
function normalizeParams(params) {
|
|
1306
|
-
if (params instanceof HttpParams)
|
|
1307
|
-
return params.toString();
|
|
1308
|
-
const paramMap = new Map();
|
|
1309
|
-
for (const [key, value] of Object.entries(params)) {
|
|
1310
|
-
if (Array.isArray(value)) {
|
|
1311
|
-
paramMap.set(key, value.map(encodeURIComponent).join(','));
|
|
1312
|
-
}
|
|
1313
|
-
else {
|
|
1314
|
-
paramMap.set(key, encodeURIComponent(value.toString()));
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
return Array.from(paramMap.entries())
|
|
1318
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
1319
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
1320
|
-
.join('&');
|
|
1321
|
-
}
|
|
1322
|
-
function urlWithParams(req) {
|
|
1323
|
-
if (!req.params)
|
|
1324
|
-
return req.url;
|
|
1325
|
-
return `${req.url}?${normalizeParams(req.params)}`;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
1486
|
function queryResource(request, options) {
|
|
1329
1487
|
const cache = injectQueryCache(options?.injector);
|
|
1330
1488
|
const destroyRef = options?.injector
|
|
@@ -1337,10 +1495,20 @@ function queryResource(request, options) {
|
|
|
1337
1495
|
const eq = options?.triggerOnSameRequest
|
|
1338
1496
|
? undefined
|
|
1339
1497
|
: createEqualRequest(options?.equal);
|
|
1498
|
+
const rawRequest = computed(() => request() ?? undefined);
|
|
1499
|
+
const disabledReason = computed(() => {
|
|
1500
|
+
if (!networkAvailable())
|
|
1501
|
+
return 'offline';
|
|
1502
|
+
if (cb.isOpen())
|
|
1503
|
+
return 'circuit-open';
|
|
1504
|
+
if (!rawRequest())
|
|
1505
|
+
return 'no-request';
|
|
1506
|
+
return null;
|
|
1507
|
+
});
|
|
1340
1508
|
const stableRequest = computed(() => {
|
|
1341
|
-
if (
|
|
1509
|
+
if (disabledReason() !== null)
|
|
1342
1510
|
return undefined;
|
|
1343
|
-
const req =
|
|
1511
|
+
const req = rawRequest();
|
|
1344
1512
|
if (!req)
|
|
1345
1513
|
return undefined;
|
|
1346
1514
|
if (typeof req === 'string')
|
|
@@ -1354,8 +1522,8 @@ function queryResource(request, options) {
|
|
|
1354
1522
|
},
|
|
1355
1523
|
});
|
|
1356
1524
|
const hashFn = typeof options?.cache === 'object'
|
|
1357
|
-
? (options.cache.hash ??
|
|
1358
|
-
:
|
|
1525
|
+
? (options.cache.hash ?? hashRequest)
|
|
1526
|
+
: hashRequest;
|
|
1359
1527
|
const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
|
|
1360
1528
|
const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
|
|
1361
1529
|
const cacheKey = computed(() => {
|
|
@@ -1416,7 +1584,7 @@ function queryResource(request, options) {
|
|
|
1416
1584
|
},
|
|
1417
1585
|
});
|
|
1418
1586
|
resource = refresh(resource, destroyRef, options?.refresh);
|
|
1419
|
-
resource = retryOnError(resource, options?.retry);
|
|
1587
|
+
resource = retryOnError(resource, options?.retry, options?.onError);
|
|
1420
1588
|
resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
|
|
1421
1589
|
const value = options?.cache
|
|
1422
1590
|
? toWritable(computed(() => {
|
|
@@ -1424,20 +1592,6 @@ function queryResource(request, options) {
|
|
|
1424
1592
|
return cacheEntry()?.value ?? resource.value();
|
|
1425
1593
|
}), resource.value.set, resource.value.update)
|
|
1426
1594
|
: resource.value;
|
|
1427
|
-
const onError = options?.onError; // Put in own variable to ensure value remains even if options are somehow mutated in-line
|
|
1428
|
-
if (onError) {
|
|
1429
|
-
const onErrorRef = effect(() => {
|
|
1430
|
-
const err = resource.error();
|
|
1431
|
-
if (err)
|
|
1432
|
-
onError(err);
|
|
1433
|
-
});
|
|
1434
|
-
// cleanup on manual destroy, I'm comfortable setting these props in-line as we have yet to 'release' the object out of this lexical scope
|
|
1435
|
-
const destroyRest = resource.destroy;
|
|
1436
|
-
resource.destroy = () => {
|
|
1437
|
-
onErrorRef.destroy();
|
|
1438
|
-
destroyRest();
|
|
1439
|
-
};
|
|
1440
|
-
}
|
|
1441
1595
|
// iterate circuit breaker state, is effect as a computed would cause a circular dependency (resource -> cb -> resource)
|
|
1442
1596
|
const cbEffectRef = effect(() => {
|
|
1443
1597
|
const status = resource.status();
|
|
@@ -1469,7 +1623,8 @@ function queryResource(request, options) {
|
|
|
1469
1623
|
update,
|
|
1470
1624
|
statusCode: linkedSignal(resource.statusCode),
|
|
1471
1625
|
headers: linkedSignal(resource.headers),
|
|
1472
|
-
disabled: computed(() =>
|
|
1626
|
+
disabled: computed(() => disabledReason() !== null),
|
|
1627
|
+
disabledReason,
|
|
1473
1628
|
reload: () => {
|
|
1474
1629
|
cb.halfOpen(); // open the circuit for manual reload
|
|
1475
1630
|
return resource.reload();
|