@mmstack/resource 20.2.6 → 20.2.8

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.
@@ -1,11 +1,17 @@
1
- import { computed, untracked, InjectionToken, inject, isDevMode, signal, effect, Injector, linkedSignal, DestroyRef } from '@angular/core';
1
+ import { computed, untracked, InjectionToken, isDevMode, inject, signal, effect, Injector, linkedSignal, DestroyRef } from '@angular/core';
2
2
  import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
3
3
  import { of, tap, map, finalize, shareReplay, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
4
- import { HttpContextToken, HttpContext, HttpResponse, HttpParams, HttpHeaders, httpResource, HttpClient } from '@angular/common/http';
4
+ import { HttpHeaders, HttpResponse, HttpContextToken, HttpContext, HttpParams, httpResource, HttpClient } from '@angular/common/http';
5
5
  import { mutable, toWritable } from '@mmstack/primitives';
6
6
  import { v7 } from 'uuid';
7
7
  import { keys, hash, entries } from '@mmstack/object';
8
8
 
9
+ function isSyncMessage(msg) {
10
+ return (typeof msg === 'object' &&
11
+ msg !== null &&
12
+ 'type' in msg &&
13
+ msg.type === 'cache-sync-message');
14
+ }
9
15
  const ONE_DAY = 1000 * 60 * 60 * 24;
10
16
  const ONE_HOUR = 1000 * 60 * 60;
11
17
  const DEFAULT_CLEANUP_OPT = {
@@ -23,6 +29,14 @@ class Cache {
23
29
  staleTime;
24
30
  internal = mutable(new Map());
25
31
  cleanupOpt;
32
+ /**
33
+ * Destroys the cache instance, cleaning up any resources used by the cache.
34
+ * This method is called automatically when the cache instance is garbage collected.
35
+ */
36
+ destroy;
37
+ broadcast = () => {
38
+ // noop
39
+ };
26
40
  /**
27
41
  * Creates a new `Cache` instance.
28
42
  *
@@ -31,12 +45,14 @@ class Cache {
31
45
  * stale but can still be used while revalidation occurs in the background. Defaults to 1 hour.
32
46
  * @param cleanupOpt - Options for configuring the cache cleanup strategy. Defaults to LRU with a
33
47
  * `maxSize` of 200 and a `checkInterval` of one hour.
48
+ * @param syncTabs - If provided, the cache will use the options a BroadcastChannel to send updates between tabs.
49
+ * Defaults to `undefined`, meaning no synchronization across tabs.
34
50
  */
35
51
  constructor(ttl = ONE_DAY, staleTime = ONE_HOUR, cleanupOpt = {
36
52
  type: 'lru',
37
53
  maxSize: 1000,
38
54
  checkInterval: ONE_HOUR,
39
- }) {
55
+ }, syncTabs) {
40
56
  this.ttl = ttl;
41
57
  this.staleTime = staleTime;
42
58
  this.cleanupOpt = {
@@ -49,18 +65,70 @@ class Cache {
49
65
  const cleanupInterval = setInterval(() => {
50
66
  this.cleanup();
51
67
  }, cleanupOpt.checkInterval);
52
- const destroyId = v7();
68
+ const cacheId = v7();
69
+ let destroySyncTabs = () => {
70
+ // noop
71
+ };
72
+ if (syncTabs) {
73
+ const channel = new BroadcastChannel(syncTabs.id);
74
+ this.broadcast = (msg) => {
75
+ if (msg.action === 'invalidate')
76
+ return channel.postMessage({
77
+ action: 'invalidate',
78
+ entry: { key: msg.entry.key },
79
+ cacheId,
80
+ type: 'cache-sync-message',
81
+ });
82
+ return channel.postMessage({
83
+ ...msg,
84
+ entry: {
85
+ ...msg.entry,
86
+ value: syncTabs.serialize(msg.entry.value),
87
+ },
88
+ cacheId,
89
+ type: 'cache-sync-message',
90
+ });
91
+ };
92
+ channel.onmessage = (event) => {
93
+ const msg = event.data;
94
+ if (!isSyncMessage(msg))
95
+ return;
96
+ if (msg.cacheId === cacheId)
97
+ return; // ignore messages from this cache
98
+ if (msg.action === 'store') {
99
+ const value = syncTabs.deserialize(msg.entry.value);
100
+ if (value === null)
101
+ return;
102
+ this.storeInternal(msg.entry.key, value, msg.entry.stale - msg.entry.updated, msg.entry.expiresAt - msg.entry.updated, true);
103
+ }
104
+ else if (msg.action === 'invalidate') {
105
+ this.invalidateInternal(msg.entry.key, true);
106
+ }
107
+ };
108
+ destroySyncTabs = () => {
109
+ channel.close();
110
+ };
111
+ }
112
+ let destroyed = false;
113
+ const destroy = () => {
114
+ if (destroyed)
115
+ return;
116
+ destroyed = true;
117
+ clearInterval(cleanupInterval);
118
+ destroySyncTabs();
119
+ };
120
+ this.destroy = destroy;
53
121
  // cleanup if object is garbage collected, this is because the cache can be quite large from a memory standpoint & we dont want all that floating garbage
54
122
  const registry = new FinalizationRegistry((id) => {
55
- if (id === destroyId) {
56
- clearInterval(cleanupInterval);
123
+ if (id === cacheId) {
124
+ destroy();
57
125
  }
58
126
  });
59
- registry.register(this, destroyId);
127
+ registry.register(this, cacheId);
60
128
  }
61
129
  /** @internal */
62
130
  getInternal(key) {
63
- const keySignal = computed(() => key());
131
+ const keySignal = computed(() => key(), ...(ngDevMode ? [{ debugName: "keySignal" }] : []));
64
132
  return computed(() => {
65
133
  const key = keySignal();
66
134
  if (!key)
@@ -116,6 +184,9 @@ class Cache {
116
184
  * @param ttl - (Optional) The TTL for this entry, in milliseconds. Overrides the default `ttl`.
117
185
  */
118
186
  store(key, value, staleTime = this.staleTime, ttl = this.ttl) {
187
+ this.storeInternal(key, value, staleTime, ttl);
188
+ }
189
+ storeInternal(key, value, staleTime = this.staleTime, ttl = this.ttl, fromSync = false) {
119
190
  const entry = this.getUntracked(key);
120
191
  if (entry) {
121
192
  clearTimeout(entry.timeout); // stop invalidation
@@ -125,18 +196,27 @@ class Cache {
125
196
  if (ttl < staleTime)
126
197
  staleTime = ttl;
127
198
  const now = Date.now();
199
+ const next = {
200
+ value,
201
+ created: entry?.created ?? now,
202
+ updated: now,
203
+ useCount: prevCount + 1,
204
+ stale: now + staleTime,
205
+ expiresAt: now + ttl,
206
+ key,
207
+ };
128
208
  this.internal.mutate((map) => {
129
209
  map.set(key, {
130
- value,
131
- created: entry?.created ?? now,
132
- useCount: prevCount + 1,
133
- stale: now + staleTime,
134
- expiresAt: now + ttl,
210
+ ...next,
135
211
  timeout: setTimeout(() => this.invalidate(key), ttl),
136
- key,
137
212
  });
138
213
  return map;
139
214
  });
215
+ if (!fromSync)
216
+ this.broadcast({
217
+ action: 'store',
218
+ entry: next,
219
+ });
140
220
  }
141
221
  /**
142
222
  * Invalidates (removes) a cache entry.
@@ -144,6 +224,9 @@ class Cache {
144
224
  * @param key - The key of the entry to invalidate.
145
225
  */
146
226
  invalidate(key) {
227
+ this.invalidateInternal(key);
228
+ }
229
+ invalidateInternal(key, fromSync = false) {
147
230
  const entry = this.getUntracked(key);
148
231
  if (!entry)
149
232
  return;
@@ -152,6 +235,8 @@ class Cache {
152
235
  map.delete(key);
153
236
  return map;
154
237
  });
238
+ if (!fromSync)
239
+ this.broadcast({ action: 'invalidate', entry: { key } });
155
240
  }
156
241
  /** @internal */
157
242
  cleanup() {
@@ -198,9 +283,53 @@ const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE');
198
283
  * };
199
284
  */
200
285
  function provideQueryCache(opt) {
286
+ const syncTabsOpt = opt?.syncTabsId
287
+ ? {
288
+ id: opt.syncTabsId,
289
+ serialize: (value) => {
290
+ const headersRecord = {};
291
+ const headerKeys = value.headers.keys();
292
+ headerKeys.forEach((key) => {
293
+ const values = value.headers.getAll(key);
294
+ if (!values)
295
+ return;
296
+ headersRecord[key] = values;
297
+ });
298
+ return JSON.stringify({
299
+ body: value.body,
300
+ status: value.status,
301
+ statusText: value.statusText,
302
+ headers: headerKeys.length > 0 ? headersRecord : undefined,
303
+ url: value.url,
304
+ });
305
+ },
306
+ deserialize: (value) => {
307
+ try {
308
+ const parsed = JSON.parse(value);
309
+ if (!parsed || typeof parsed !== 'object' || !('body' in parsed))
310
+ throw new Error('Invalid cache entry format');
311
+ const headers = parsed.headers
312
+ ? new HttpHeaders(parsed.headers)
313
+ : undefined;
314
+ return new HttpResponse({
315
+ body: parsed.body,
316
+ status: parsed.status,
317
+ statusText: parsed.statusText,
318
+ headers: headers,
319
+ url: parsed.url,
320
+ });
321
+ }
322
+ catch (err) {
323
+ if (isDevMode())
324
+ console.error('Failed to deserialize cache entry:', err);
325
+ return null;
326
+ }
327
+ },
328
+ }
329
+ : undefined;
201
330
  return {
202
331
  provide: CLIENT_CACHE_TOKEN,
203
- useValue: new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup),
332
+ useValue: new Cache(opt?.ttl, opt?.staleTime, opt?.cleanup, syncTabsOpt),
204
333
  };
205
334
  }
206
335
  class NoopCache extends Cache {
@@ -471,15 +600,15 @@ const DEFAULT_OPTIONS = {
471
600
  };
472
601
  /** @internal */
473
602
  function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldFail = () => true, shouldFailForever = () => false) {
474
- const halfOpen = signal(false);
475
- const failureCount = signal(0);
603
+ const halfOpen = signal(false, ...(ngDevMode ? [{ debugName: "halfOpen" }] : []));
604
+ const failureCount = signal(0, ...(ngDevMode ? [{ debugName: "failureCount" }] : []));
476
605
  const status = computed(() => {
477
606
  if (failureCount() >= treshold)
478
607
  return 'OPEN';
479
608
  return halfOpen() ? 'HALF_OPEN' : 'CLOSED';
480
- });
481
- const isClosed = computed(() => status() !== 'OPEN');
482
- const isOpen = computed(() => status() !== 'CLOSED');
609
+ }, ...(ngDevMode ? [{ debugName: "status" }] : []));
610
+ const isClosed = computed(() => status() !== 'OPEN', ...(ngDevMode ? [{ debugName: "isClosed" }] : []));
611
+ const isOpen = computed(() => status() !== 'CLOSED', ...(ngDevMode ? [{ debugName: "isOpen" }] : []));
483
612
  const success = () => {
484
613
  failureCount.set(0);
485
614
  halfOpen.set(false);
@@ -500,7 +629,7 @@ function internalCeateCircuitBreaker(treshold = 5, resetTimeout = 30000, shouldF
500
629
  clearTimeout(timeout);
501
630
  failForeverResetId = null;
502
631
  });
503
- });
632
+ }, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
504
633
  const failInternal = () => {
505
634
  failureCount.set(failureCount() + 1);
506
635
  halfOpen.set(false);
@@ -904,7 +1033,7 @@ function retryOnError(res, opt) {
904
1033
  case 'resolved':
905
1034
  return onSuccess();
906
1035
  }
907
- });
1036
+ }, ...(ngDevMode ? [{ debugName: "ref" }] : []));
908
1037
  return {
909
1038
  ...res,
910
1039
  destroy: () => {
@@ -967,11 +1096,13 @@ function queryResource(request, options) {
967
1096
  if (cb.isOpen())
968
1097
  return undefined;
969
1098
  return request() ?? undefined;
970
- }, {
971
- equal: options?.triggerOnSameRequest
972
- ? undefined
973
- : createEqualRequest(options?.equal),
974
- });
1099
+ }, ...(ngDevMode ? [{ debugName: "stableRequest", equal: options?.triggerOnSameRequest
1100
+ ? undefined
1101
+ : createEqualRequest(options?.equal) }] : [{
1102
+ equal: options?.triggerOnSameRequest
1103
+ ? undefined
1104
+ : createEqualRequest(options?.equal),
1105
+ }]));
975
1106
  const hashFn = typeof options?.cache === 'object'
976
1107
  ? (options.cache.hash ?? urlWithParams)
977
1108
  : urlWithParams;
@@ -982,7 +1113,7 @@ function queryResource(request, options) {
982
1113
  if (!r)
983
1114
  return null;
984
1115
  return hashFn(r);
985
- });
1116
+ }, ...(ngDevMode ? [{ debugName: "cacheKey" }] : []));
986
1117
  const bustBrowserCache = typeof options?.cache === 'object' &&
987
1118
  options.cache.bustBrowserCache === true;
988
1119
  const ignoreCacheControl = typeof options?.cache === 'object' &&
@@ -1048,7 +1179,7 @@ function queryResource(request, options) {
1048
1179
  const err = resource.error();
1049
1180
  if (err)
1050
1181
  onError(err);
1051
- });
1182
+ }, ...(ngDevMode ? [{ debugName: "onErrorRef" }] : []));
1052
1183
  // 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
1053
1184
  const destroyRest = resource.destroy;
1054
1185
  resource.destroy = () => {
@@ -1063,7 +1194,7 @@ function queryResource(request, options) {
1063
1194
  cb.fail(untracked(resource.error));
1064
1195
  else if (status === 'resolved')
1065
1196
  cb.success();
1066
- });
1197
+ }, ...(ngDevMode ? [{ debugName: "cbEffectRef" }] : []));
1067
1198
  const set = (value) => {
1068
1199
  resource.value.set(value);
1069
1200
  const k = untracked(cacheKey);
@@ -1117,6 +1248,11 @@ function queryResource(request, options) {
1117
1248
  try {
1118
1249
  await firstValueFrom(client.request(prefetchRequest.method ?? 'GET', prefetchRequest.url, {
1119
1250
  ...prefetchRequest,
1251
+ credentials: prefetchRequest.credentials,
1252
+ priority: prefetchRequest.priority,
1253
+ cache: prefetchRequest.cache,
1254
+ mode: prefetchRequest.mode,
1255
+ redirect: prefetchRequest.redirect,
1120
1256
  context: setCacheContext(prefetchRequest.context, {
1121
1257
  staleTime,
1122
1258
  ttl,
@@ -1164,23 +1300,29 @@ function mutationResource(request, options = {}) {
1164
1300
  const { onMutate, onError, onSuccess, onSettled, equal, ...rest } = options;
1165
1301
  const requestEqual = createEqualRequest(equal);
1166
1302
  const eq = equal ?? Object.is;
1167
- const next = signal(null, {
1168
- equal: (a, b) => {
1169
- if (!a && !b)
1170
- return true;
1171
- if (!a || !b)
1172
- return false;
1173
- return eq(a, b);
1174
- },
1175
- });
1303
+ const next = signal(null, ...(ngDevMode ? [{ debugName: "next", equal: (a, b) => {
1304
+ if (!a && !b)
1305
+ return true;
1306
+ if (!a || !b)
1307
+ return false;
1308
+ return eq(a, b);
1309
+ } }] : [{
1310
+ equal: (a, b) => {
1311
+ if (!a && !b)
1312
+ return true;
1313
+ if (!a || !b)
1314
+ return false;
1315
+ return eq(a, b);
1316
+ },
1317
+ }]));
1176
1318
  const req = computed(() => {
1177
1319
  const nr = next();
1178
1320
  if (!nr)
1179
1321
  return;
1180
1322
  return request(nr) ?? undefined;
1181
- }, {
1182
- equal: requestEqual,
1183
- });
1323
+ }, ...(ngDevMode ? [{ debugName: "req", equal: requestEqual }] : [{
1324
+ equal: requestEqual,
1325
+ }]));
1184
1326
  const lastValue = linkedSignal({
1185
1327
  source: next,
1186
1328
  computation: (next, prev) => {
@@ -1194,9 +1336,9 @@ function mutationResource(request, options = {}) {
1194
1336
  if (!nr)
1195
1337
  return;
1196
1338
  return request(nr) ?? undefined;
1197
- }, {
1198
- equal: requestEqual,
1199
- });
1339
+ }, ...(ngDevMode ? [{ debugName: "lastValueRequest", equal: requestEqual }] : [{
1340
+ equal: requestEqual,
1341
+ }]));
1200
1342
  const cb = createCircuitBreaker(options?.circuitBreaker === true
1201
1343
  ? undefined
1202
1344
  : (options?.circuitBreaker ?? false), options?.injector);