@mmstack/resource 20.5.1 → 20.6.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/index.d.ts +83 -10
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, linkedSignal, 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, ...(ngDevMode ? [{ debugName: "halfOpen" }] : []));
|
|
739
950
|
const failureCount = signal(0, ...(ngDevMode ? [{ debugName: "failureCount" }] : []));
|
|
951
|
+
const failedForever = signal(false, ...(ngDevMode ? [{ debugName: "failedForever" }] : []));
|
|
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
|
}, ...(ngDevMode ? [{ debugName: "status" }] : []));
|
|
@@ -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
|
}, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
|
|
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;
|
|
@@ -1240,13 +1418,16 @@ function refresh(resource, destroyRef, refresh) {
|
|
|
1240
1418
|
}
|
|
1241
1419
|
|
|
1242
1420
|
// Retry on error, if number is provided it will retry that many times with exponential backoff, otherwise it will use the options provided
|
|
1243
|
-
function retryOnError(res, opt) {
|
|
1421
|
+
function retryOnError(res, opt, onError) {
|
|
1244
1422
|
const max = opt ? (typeof opt === 'number' ? opt : (opt.max ?? 0)) : 0;
|
|
1245
1423
|
const backoff = typeof opt === 'object' ? (opt.backoff ?? 1000) : 1000;
|
|
1246
1424
|
let retries = 0;
|
|
1247
1425
|
let timeout;
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1426
|
+
const handleError = () => {
|
|
1427
|
+
const err = untracked(res.error);
|
|
1428
|
+
const isFinal = retries >= max;
|
|
1429
|
+
onError?.(err, retries, isFinal);
|
|
1430
|
+
if (isFinal)
|
|
1250
1431
|
return;
|
|
1251
1432
|
retries++;
|
|
1252
1433
|
if (timeout)
|
|
@@ -1261,7 +1442,7 @@ function retryOnError(res, opt) {
|
|
|
1261
1442
|
const ref = effect(() => {
|
|
1262
1443
|
switch (res.status()) {
|
|
1263
1444
|
case 'error':
|
|
1264
|
-
return
|
|
1445
|
+
return handleError();
|
|
1265
1446
|
case 'resolved':
|
|
1266
1447
|
return onSuccess();
|
|
1267
1448
|
}
|
|
@@ -1308,29 +1489,6 @@ function toResourceObject(res) {
|
|
|
1308
1489
|
};
|
|
1309
1490
|
}
|
|
1310
1491
|
|
|
1311
|
-
function normalizeParams(params) {
|
|
1312
|
-
if (params instanceof HttpParams)
|
|
1313
|
-
return params.toString();
|
|
1314
|
-
const paramMap = new Map();
|
|
1315
|
-
for (const [key, value] of Object.entries(params)) {
|
|
1316
|
-
if (Array.isArray(value)) {
|
|
1317
|
-
paramMap.set(key, value.map(encodeURIComponent).join(','));
|
|
1318
|
-
}
|
|
1319
|
-
else {
|
|
1320
|
-
paramMap.set(key, encodeURIComponent(value.toString()));
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
return Array.from(paramMap.entries())
|
|
1324
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
1325
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
1326
|
-
.join('&');
|
|
1327
|
-
}
|
|
1328
|
-
function urlWithParams(req) {
|
|
1329
|
-
if (!req.params)
|
|
1330
|
-
return req.url;
|
|
1331
|
-
return `${req.url}?${normalizeParams(req.params)}`;
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
1492
|
function queryResource(request, options) {
|
|
1335
1493
|
const cache = injectQueryCache(options?.injector);
|
|
1336
1494
|
const destroyRef = options?.injector
|
|
@@ -1343,10 +1501,20 @@ function queryResource(request, options) {
|
|
|
1343
1501
|
const eq = options?.triggerOnSameRequest
|
|
1344
1502
|
? undefined
|
|
1345
1503
|
: createEqualRequest(options?.equal);
|
|
1504
|
+
const rawRequest = computed(() => request() ?? undefined, ...(ngDevMode ? [{ debugName: "rawRequest" }] : []));
|
|
1505
|
+
const disabledReason = computed(() => {
|
|
1506
|
+
if (!networkAvailable())
|
|
1507
|
+
return 'offline';
|
|
1508
|
+
if (cb.isOpen())
|
|
1509
|
+
return 'circuit-open';
|
|
1510
|
+
if (!rawRequest())
|
|
1511
|
+
return 'no-request';
|
|
1512
|
+
return null;
|
|
1513
|
+
}, ...(ngDevMode ? [{ debugName: "disabledReason" }] : []));
|
|
1346
1514
|
const stableRequest = computed(() => {
|
|
1347
|
-
if (
|
|
1515
|
+
if (disabledReason() !== null)
|
|
1348
1516
|
return undefined;
|
|
1349
|
-
const req =
|
|
1517
|
+
const req = rawRequest();
|
|
1350
1518
|
if (!req)
|
|
1351
1519
|
return undefined;
|
|
1352
1520
|
if (typeof req === 'string')
|
|
@@ -1364,8 +1532,8 @@ function queryResource(request, options) {
|
|
|
1364
1532
|
},
|
|
1365
1533
|
}]));
|
|
1366
1534
|
const hashFn = typeof options?.cache === 'object'
|
|
1367
|
-
? (options.cache.hash ??
|
|
1368
|
-
:
|
|
1535
|
+
? (options.cache.hash ?? hashRequest)
|
|
1536
|
+
: hashRequest;
|
|
1369
1537
|
const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
|
|
1370
1538
|
const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
|
|
1371
1539
|
const cacheKey = computed(() => {
|
|
@@ -1444,7 +1612,7 @@ function queryResource(request, options) {
|
|
|
1444
1612
|
},
|
|
1445
1613
|
}]));
|
|
1446
1614
|
resource = refresh(resource, destroyRef, options?.refresh);
|
|
1447
|
-
resource = retryOnError(resource, options?.retry);
|
|
1615
|
+
resource = retryOnError(resource, options?.retry, options?.onError);
|
|
1448
1616
|
resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
|
|
1449
1617
|
const value = options?.cache
|
|
1450
1618
|
? toWritable(computed(() => {
|
|
@@ -1452,20 +1620,6 @@ function queryResource(request, options) {
|
|
|
1452
1620
|
return cacheEntry()?.value ?? resource.value();
|
|
1453
1621
|
}), resource.value.set, resource.value.update)
|
|
1454
1622
|
: resource.value;
|
|
1455
|
-
const onError = options?.onError; // Put in own variable to ensure value remains even if options are somehow mutated in-line
|
|
1456
|
-
if (onError) {
|
|
1457
|
-
const onErrorRef = effect(() => {
|
|
1458
|
-
const err = resource.error();
|
|
1459
|
-
if (err)
|
|
1460
|
-
onError(err);
|
|
1461
|
-
}, ...(ngDevMode ? [{ debugName: "onErrorRef" }] : []));
|
|
1462
|
-
// 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
|
|
1463
|
-
const destroyRest = resource.destroy;
|
|
1464
|
-
resource.destroy = () => {
|
|
1465
|
-
onErrorRef.destroy();
|
|
1466
|
-
destroyRest();
|
|
1467
|
-
};
|
|
1468
|
-
}
|
|
1469
1623
|
// iterate circuit breaker state, is effect as a computed would cause a circular dependency (resource -> cb -> resource)
|
|
1470
1624
|
const cbEffectRef = effect(() => {
|
|
1471
1625
|
const status = resource.status();
|
|
@@ -1497,7 +1651,8 @@ function queryResource(request, options) {
|
|
|
1497
1651
|
update,
|
|
1498
1652
|
statusCode: linkedSignal(resource.statusCode),
|
|
1499
1653
|
headers: linkedSignal(resource.headers),
|
|
1500
|
-
disabled: computed(() =>
|
|
1654
|
+
disabled: computed(() => disabledReason() !== null),
|
|
1655
|
+
disabledReason,
|
|
1501
1656
|
reload: () => {
|
|
1502
1657
|
cb.halfOpen(); // open the circuit for manual reload
|
|
1503
1658
|
return resource.reload();
|