@mmstack/resource 20.2.7 → 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,14 +65,66 @@ 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) {
@@ -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 {