zapo-js 1.8.2 → 1.10.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.
Files changed (59) hide show
  1. package/README.md +14 -13
  2. package/dist/auth/credentials-flow.js +2 -3
  3. package/dist/client/WaClient.d.ts +4 -0
  4. package/dist/client/WaClient.js +5 -1
  5. package/dist/client/coordinators/WaMessageCoordinator.d.ts +11 -4
  6. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +11 -0
  7. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +15 -5
  8. package/dist/client/messaging/ignore-key.d.ts +2 -1
  9. package/dist/client/messaging/ignore-key.js +24 -3
  10. package/dist/esm/auth/credentials-flow.js +3 -4
  11. package/dist/esm/client/WaClient.js +5 -1
  12. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -5
  13. package/dist/esm/client/messaging/ignore-key.js +25 -4
  14. package/dist/esm/message/encode/content.js +4 -1
  15. package/dist/esm/store/cache/identity.cache.js +30 -5
  16. package/dist/esm/store/cache/privacy-token.cache.js +30 -5
  17. package/dist/esm/store/cache/sender-key.cache.js +38 -7
  18. package/dist/esm/store/cache/session.cache.js +30 -4
  19. package/dist/esm/store/createStore.js +39 -4
  20. package/dist/esm/store/memory/identity.store.js +28 -4
  21. package/dist/esm/store/memory/privacy-token.store.js +24 -7
  22. package/dist/esm/store/memory/sender-key.store.js +36 -7
  23. package/dist/esm/store/memory/session.store.js +41 -7
  24. package/dist/esm/transport/node/WaMobileTcpSocket.js +69 -9
  25. package/dist/esm/transport/noise/WaMobileClientPayload.js +27 -7
  26. package/dist/esm/transport/proxy.js +83 -0
  27. package/dist/esm/util/collections.js +94 -0
  28. package/dist/esm/util/index.js +2 -2
  29. package/dist/message/encode/content.js +4 -1
  30. package/dist/message/primitives/media-retry.d.ts +11 -0
  31. package/dist/store/cache/identity.cache.d.ts +6 -4
  32. package/dist/store/cache/identity.cache.js +30 -5
  33. package/dist/store/cache/privacy-token.cache.d.ts +6 -4
  34. package/dist/store/cache/privacy-token.cache.js +30 -5
  35. package/dist/store/cache/sender-key.cache.d.ts +5 -3
  36. package/dist/store/cache/sender-key.cache.js +38 -7
  37. package/dist/store/cache/session.cache.d.ts +6 -3
  38. package/dist/store/cache/session.cache.js +30 -4
  39. package/dist/store/createStore.js +39 -4
  40. package/dist/store/memory/identity.store.d.ts +7 -1
  41. package/dist/store/memory/identity.store.js +28 -4
  42. package/dist/store/memory/privacy-token.store.d.ts +7 -1
  43. package/dist/store/memory/privacy-token.store.js +24 -7
  44. package/dist/store/memory/sender-key.store.d.ts +12 -2
  45. package/dist/store/memory/sender-key.store.js +36 -7
  46. package/dist/store/memory/session.store.d.ts +7 -1
  47. package/dist/store/memory/session.store.js +41 -7
  48. package/dist/store/types.d.ts +43 -4
  49. package/dist/transport/node/WaMobileTcpSocket.d.ts +3 -1
  50. package/dist/transport/node/WaMobileTcpSocket.js +68 -8
  51. package/dist/transport/noise/WaMobileClientPayload.d.ts +22 -0
  52. package/dist/transport/noise/WaMobileClientPayload.js +27 -7
  53. package/dist/transport/proxy.d.ts +24 -0
  54. package/dist/transport/proxy.js +85 -0
  55. package/dist/util/collections.d.ts +46 -0
  56. package/dist/util/collections.js +98 -0
  57. package/dist/util/index.d.ts +2 -2
  58. package/dist/util/index.js +3 -1
  59. package/package.json +1 -1
@@ -24,6 +24,100 @@ export function normalizeQueryLimit(limit, defaultLimit) {
24
24
  }
25
25
  return limit;
26
26
  }
27
+ /**
28
+ * Sweep period shared by a set of idle TTLs: the smallest `ttlMs / 2`,
29
+ * clamped to [1 s, 60 s].
30
+ */
31
+ export function resolveIdleSweepPeriodMs(ttlsMs) {
32
+ let periodMs = 60000;
33
+ for (let i = 0; i < ttlsMs.length; i += 1) {
34
+ periodMs = Math.min(periodMs, Math.max(1000, Math.floor(ttlsMs[i] / 2)));
35
+ }
36
+ return periodMs;
37
+ }
38
+ /**
39
+ * Coarse clock for idle expiry: one `unref`'d interval bumps `tick` and runs
40
+ * every registered sweep, and only while a sweep is registered. Wall-clock
41
+ * steps never move `tick`.
42
+ */
43
+ export class IdleSweepClock {
44
+ constructor(periodMs) {
45
+ this.advance = () => {
46
+ this.currentTick += 1;
47
+ for (const sweep of this.sweeps)
48
+ sweep(this.currentTick);
49
+ };
50
+ this.periodMs = periodMs;
51
+ this.currentTick = 0;
52
+ this.sweeps = new Set();
53
+ this.timer = null;
54
+ }
55
+ get tick() {
56
+ return this.currentTick;
57
+ }
58
+ /** Runs `sweep` on every tick until the returned function is called. */
59
+ register(sweep) {
60
+ this.sweeps.add(sweep);
61
+ if (this.timer === null) {
62
+ this.timer = setInterval(this.advance, this.periodMs);
63
+ this.timer.unref?.();
64
+ }
65
+ return () => {
66
+ this.sweeps.delete(sweep);
67
+ if (this.sweeps.size > 0 || this.timer === null)
68
+ return;
69
+ clearInterval(this.timer);
70
+ this.timer = null;
71
+ };
72
+ }
73
+ }
74
+ /**
75
+ * Idle expiry and LRU order for the entries of `data`. A touch moves the key
76
+ * to the young end of the index and of `data`, at most once per tick, so the
77
+ * sweep stops at the first live key and `data` iterates least recently used
78
+ * first. A key expires after `ceil(ttlMs / periodMs) + 1` untouched ticks:
79
+ * idle for at least `ttlMs` and, timer lateness aside, under `ttlMs + 2 * periodMs`.
80
+ */
81
+ export class IdleExpiryIndex {
82
+ constructor(data, expiry) {
83
+ this.sweep = (tick) => {
84
+ const cutoff = tick - this.idleTicks;
85
+ for (const [key, touchedAt] of this.touchedAt) {
86
+ if (touchedAt > cutoff)
87
+ return;
88
+ this.touchedAt.delete(key);
89
+ this.data.delete(key);
90
+ }
91
+ };
92
+ this.data = data;
93
+ this.clock = expiry.clock;
94
+ this.idleTicks = Math.ceil(expiry.ttlMs / expiry.clock.periodMs) + 1;
95
+ this.touchedAt = new Map();
96
+ this.unregister = expiry.clock.register(this.sweep);
97
+ }
98
+ touch(key) {
99
+ const tick = this.clock.tick;
100
+ if (this.touchedAt.get(key) === tick)
101
+ return;
102
+ this.touchedAt.delete(key);
103
+ this.touchedAt.set(key, tick);
104
+ const value = this.data.get(key);
105
+ if (value === undefined)
106
+ return;
107
+ this.data.delete(key);
108
+ this.data.set(key, value);
109
+ }
110
+ delete(key) {
111
+ this.touchedAt.delete(key);
112
+ }
113
+ clear() {
114
+ this.touchedAt.clear();
115
+ }
116
+ /** Stops sweeping; the entries stay until the owner clears them. */
117
+ detach() {
118
+ this.unregister();
119
+ }
120
+ }
27
121
  export function setBoundedMapEntry(map, key, value, maxEntries, onEvict) {
28
122
  map.delete(key);
29
123
  map.set(key, value);
@@ -1,6 +1,6 @@
1
1
  export { delay } from './async.js';
2
2
  export { base64ToBytes, bytesToBase64, bytesToBase64UrlSafe, bytesToHex, concatBytes, decodeBase64Url, EMPTY_BYTES, hexToBytes, TEXT_DECODER, TEXT_ENCODER, toBytesView, uint8Equal, uint8TimingSafeEqual } from './bytes.js';
3
- export { asBytes, asNumber, asOptionalBytes, asOptionalNumber, asOptionalString, asString, resolveOptionalPositive, resolvePositive, toBoolOrUndef } from './coercion.js';
4
- export { normalizeQueryLimit } from './collections.js';
3
+ export { asBytes, asNumber, asOptionalBytes, asOptionalNumber, asOptionalString, asString, resolveOptionalPositive, resolvePositive, toBoolOrUndef, tryAsNumber } from './coercion.js';
4
+ export { normalizeQueryLimit, setBoundedMapEntry } from './collections.js';
5
5
  export { toError, toSafeNumber } from './primitives.js';
6
6
  export { isBunRuntime } from './runtime.js';
@@ -180,7 +180,8 @@ function unwrapMessage(message) {
180
180
  msg.viewOnceMessageV2?.message ??
181
181
  msg.documentWithCaptionMessage?.message ??
182
182
  msg.groupStatusMessage?.message ??
183
- msg.groupStatusMessageV2?.message;
183
+ msg.groupStatusMessageV2?.message ??
184
+ msg.botForwardedMessage?.message;
184
185
  if (!inner)
185
186
  return msg;
186
187
  msg = inner;
@@ -228,6 +229,8 @@ function resolveMessageTypeAttrFrom(msg) {
228
229
  msg.pollResultSnapshotMessageV3 ||
229
230
  msg.templateButtonReplyMessage ||
230
231
  msg.messageHistoryNotice ||
232
+ msg.richResponseMessage ||
233
+ msg.botForwardedMessage ||
231
234
  msg.secretEncryptedMessage?.secretEncType ===
232
235
  _proto_1.proto.Message.SecretEncryptedMessage.SecretEncType.MESSAGE_EDIT ||
233
236
  msg.secretEncryptedMessage?.secretEncType ===
@@ -2,6 +2,17 @@ import type { WaAuthCredentials } from '../../auth/types';
2
2
  import type { Logger } from '../../infra/log/types';
3
3
  import type { BinaryNode } from '../../transport/types';
4
4
  export type WaMediaRetryResultType = 'success' | 'not_found' | 'decryption_error' | 'general_error';
5
+ /**
6
+ * Answer to a media reupload request.
7
+ *
8
+ * On `result: 'success'` the fresh `directPath` usually points at the same
9
+ * ciphertext the original message described, so its media key, hashes and
10
+ * length stay valid. Some primaries re-encrypt the file on every re-upload
11
+ * instead: the answer is still `success`, but the re-served blob no longer
12
+ * matches `fileEncSha256` and decryption fails with a MAC mismatch. Nothing in
13
+ * the round-trip carries key material for the new ciphertext, so treat that
14
+ * message as unrecoverable rather than retrying.
15
+ */
5
16
  export interface WaMediaRetryResult {
6
17
  readonly messageId: string;
7
18
  readonly result: WaMediaRetryResultType;
@@ -1,15 +1,17 @@
1
1
  import type { WaIdentityStore } from '../contracts/identity.store';
2
2
  import type { WithDestroyLifecycle } from '../types';
3
+ import { type IdleExpiry } from '../../util/collections';
3
4
  /**
4
5
  * Read-through / write-through in-process cache for a persistent remote
5
- * identity backend. Reuses {@link WaIdentityMemoryStore} as the bounded-LRU
6
- * L1. Remote identities are read alongside sessions on the send path (the
6
+ * identity backend. Reuses {@link WaIdentityMemoryStore} as the bounded L1.
7
+ * Remote identities are read alongside sessions on the send path (the
7
8
  * identity-mismatch guard), so caching them complements
8
- * {@link withSessionCache}.
9
+ * {@link withSessionCache}, which also describes the eviction with and
10
+ * without `expiry`.
9
11
  *
10
12
  * The identity store has no per-key delete: identities are overwritten on
11
13
  * re-establishment, so a peer's key change propagates through the
12
14
  * write-through `setRemoteIdentity`. See {@link withSessionCache} for the
13
15
  * shared coherence model and the single-writer-per-session assumption.
14
16
  */
15
- export declare function withIdentityCache(backend: WaIdentityStore, maxEntries?: number): WithDestroyLifecycle<WaIdentityStore>;
17
+ export declare function withIdentityCache(backend: WaIdentityStore, maxEntries?: number, expiry?: IdleExpiry): WithDestroyLifecycle<WaIdentityStore>;
@@ -2,20 +2,43 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withIdentityCache = withIdentityCache;
4
4
  const identity_store_1 = require("../memory/identity.store");
5
+ const collections_1 = require("../../util/collections");
6
+ /** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
7
+ class IdleIdentityMemoryStore extends identity_store_1.WaIdentityMemoryStore {
8
+ constructor(maxRemoteIdentities, expiry) {
9
+ super({ maxRemoteIdentities });
10
+ this.idle = new collections_1.IdleExpiryIndex(this.remoteIdentities, expiry);
11
+ }
12
+ detach() {
13
+ this.idle.detach();
14
+ }
15
+ onEntryAccess(key) {
16
+ this.idle.touch(key);
17
+ }
18
+ onEntryRemove(key) {
19
+ this.idle.delete(key);
20
+ }
21
+ onEntriesClear() {
22
+ this.idle.clear();
23
+ }
24
+ }
5
25
  /**
6
26
  * Read-through / write-through in-process cache for a persistent remote
7
- * identity backend. Reuses {@link WaIdentityMemoryStore} as the bounded-LRU
8
- * L1. Remote identities are read alongside sessions on the send path (the
27
+ * identity backend. Reuses {@link WaIdentityMemoryStore} as the bounded L1.
28
+ * Remote identities are read alongside sessions on the send path (the
9
29
  * identity-mismatch guard), so caching them complements
10
- * {@link withSessionCache}.
30
+ * {@link withSessionCache}, which also describes the eviction with and
31
+ * without `expiry`.
11
32
  *
12
33
  * The identity store has no per-key delete: identities are overwritten on
13
34
  * re-establishment, so a peer's key change propagates through the
14
35
  * write-through `setRemoteIdentity`. See {@link withSessionCache} for the
15
36
  * shared coherence model and the single-writer-per-session assumption.
16
37
  */
17
- function withIdentityCache(backend, maxEntries) {
18
- const l1 = new identity_store_1.WaIdentityMemoryStore({ maxRemoteIdentities: maxEntries });
38
+ function withIdentityCache(backend, maxEntries, expiry) {
39
+ const l1 = expiry
40
+ ? new IdleIdentityMemoryStore(maxEntries, expiry)
41
+ : new identity_store_1.WaIdentityMemoryStore({ maxRemoteIdentities: maxEntries });
19
42
  let generation = 0;
20
43
  return {
21
44
  getRemoteIdentity: async (address) => {
@@ -71,6 +94,8 @@ function withIdentityCache(backend, maxEntries) {
71
94
  await l1.clear();
72
95
  },
73
96
  destroy: async () => {
97
+ if (l1 instanceof IdleIdentityMemoryStore)
98
+ l1.detach();
74
99
  await l1.clear();
75
100
  await backend.destroy?.();
76
101
  }
@@ -1,15 +1,17 @@
1
1
  import type { WaPrivacyTokenStore } from '../contracts/privacy-token.store';
2
2
  import type { WithDestroyLifecycle } from '../types';
3
+ import { type IdleExpiry } from '../../util/collections';
3
4
  /**
4
5
  * Read-through cache for a persistent privacy-token backend. Reuses
5
- * {@link WaPrivacyTokenMemoryStore} as the bounded-LRU L1.
6
+ * {@link WaPrivacyTokenMemoryStore} as the bounded L1.
6
7
  *
7
8
  * Unlike the signal caches this is **invalidate-on-write, not
8
9
  * write-through**: `upsert` merges partial fields into the existing row on
9
10
  * the backend, so caching the partial incoming record would diverge from the
10
11
  * backend's merged result. Instead each upsert drops the L1 entry and the
11
12
  * next read re-populates from the merged backend truth. See
12
- * {@link withSessionCache} for the shared coherence model and the
13
- * single-writer-per-session assumption.
13
+ * {@link withSessionCache} for the shared coherence model, the
14
+ * single-writer-per-session assumption and the eviction with and without
15
+ * `expiry`.
14
16
  */
15
- export declare function withPrivacyTokenCache(backend: WaPrivacyTokenStore, maxEntries?: number): WithDestroyLifecycle<WaPrivacyTokenStore>;
17
+ export declare function withPrivacyTokenCache(backend: WaPrivacyTokenStore, maxEntries?: number, expiry?: IdleExpiry): WithDestroyLifecycle<WaPrivacyTokenStore>;
@@ -2,20 +2,43 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withPrivacyTokenCache = withPrivacyTokenCache;
4
4
  const privacy_token_store_1 = require("../memory/privacy-token.store");
5
+ const collections_1 = require("../../util/collections");
6
+ /** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
7
+ class IdlePrivacyTokenMemoryStore extends privacy_token_store_1.WaPrivacyTokenMemoryStore {
8
+ constructor(maxEntries, expiry) {
9
+ super(maxEntries);
10
+ this.idle = new collections_1.IdleExpiryIndex(this.records, expiry);
11
+ }
12
+ detach() {
13
+ this.idle.detach();
14
+ }
15
+ onEntryAccess(jid) {
16
+ this.idle.touch(jid);
17
+ }
18
+ onEntryRemove(jid) {
19
+ this.idle.delete(jid);
20
+ }
21
+ onEntriesClear() {
22
+ this.idle.clear();
23
+ }
24
+ }
5
25
  /**
6
26
  * Read-through cache for a persistent privacy-token backend. Reuses
7
- * {@link WaPrivacyTokenMemoryStore} as the bounded-LRU L1.
27
+ * {@link WaPrivacyTokenMemoryStore} as the bounded L1.
8
28
  *
9
29
  * Unlike the signal caches this is **invalidate-on-write, not
10
30
  * write-through**: `upsert` merges partial fields into the existing row on
11
31
  * the backend, so caching the partial incoming record would diverge from the
12
32
  * backend's merged result. Instead each upsert drops the L1 entry and the
13
33
  * next read re-populates from the merged backend truth. See
14
- * {@link withSessionCache} for the shared coherence model and the
15
- * single-writer-per-session assumption.
34
+ * {@link withSessionCache} for the shared coherence model, the
35
+ * single-writer-per-session assumption and the eviction with and without
36
+ * `expiry`.
16
37
  */
17
- function withPrivacyTokenCache(backend, maxEntries) {
18
- const l1 = new privacy_token_store_1.WaPrivacyTokenMemoryStore(maxEntries);
38
+ function withPrivacyTokenCache(backend, maxEntries, expiry) {
39
+ const l1 = expiry
40
+ ? new IdlePrivacyTokenMemoryStore(maxEntries, expiry)
41
+ : new privacy_token_store_1.WaPrivacyTokenMemoryStore(maxEntries);
19
42
  let generation = 0;
20
43
  return {
21
44
  upsert: async (record) => {
@@ -53,6 +76,8 @@ function withPrivacyTokenCache(backend, maxEntries) {
53
76
  await l1.clear();
54
77
  },
55
78
  destroy: async () => {
79
+ if (l1 instanceof IdlePrivacyTokenMemoryStore)
80
+ l1.detach();
56
81
  await l1.clear();
57
82
  await backend.destroy?.();
58
83
  }
@@ -1,8 +1,9 @@
1
1
  import type { WaSenderKeyStore } from '../contracts/sender-key.store';
2
2
  import type { WithDestroyLifecycle } from '../types';
3
+ import { type IdleExpiry } from '../../util/collections';
3
4
  /**
4
5
  * Read-through / write-through in-process cache for a persistent sender-key
5
- * backend. Reuses {@link SenderKeyMemoryStore} as the bounded-LRU L1 so the
6
+ * backend. Reuses {@link SenderKeyMemoryStore} as the bounded L1 so the
6
7
  * per-(group, sender) lookups repeated across a group fan-out skip the
7
8
  * backend round-trip.
8
9
  *
@@ -13,6 +14,7 @@ import type { WithDestroyLifecycle } from '../types';
13
14
  * (`deleteDeviceSenderKey`/`markForgetSenderKey`) run on the backend for the
14
15
  * authoritative count and then invalidate the matching L1 entries via the
15
16
  * memory store's own sweep. See {@link withSessionCache} for the shared
16
- * coherence model and the single-writer-per-session assumption.
17
+ * coherence model, the single-writer-per-session assumption and the eviction
18
+ * with and without `expiry`.
17
19
  */
18
- export declare function withSenderKeyCache(backend: WaSenderKeyStore, maxEntries?: number): WithDestroyLifecycle<WaSenderKeyStore>;
20
+ export declare function withSenderKeyCache(backend: WaSenderKeyStore, maxEntries?: number, expiry?: IdleExpiry): WithDestroyLifecycle<WaSenderKeyStore>;
@@ -2,9 +2,35 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withSenderKeyCache = withSenderKeyCache;
4
4
  const sender_key_store_1 = require("../memory/sender-key.store");
5
+ const collections_1 = require("../../util/collections");
6
+ /** L1 with idle expiry; hits keep both maps in access order, so the caps evict LRU entries. */
7
+ class IdleSenderKeyMemoryStore extends sender_key_store_1.SenderKeyMemoryStore {
8
+ constructor(maxEntries, expiry) {
9
+ super({ maxSenderKeys: maxEntries, maxSenderDistributions: maxEntries });
10
+ this.idleSenderKeys = new collections_1.IdleExpiryIndex(this.senderKeys, expiry);
11
+ this.idleDistributions = new collections_1.IdleExpiryIndex(this.senderDistributions, expiry);
12
+ }
13
+ detach() {
14
+ this.idleSenderKeys.detach();
15
+ this.idleDistributions.detach();
16
+ }
17
+ onEntryAccess(entries, key) {
18
+ this.indexFor(entries).touch(key);
19
+ }
20
+ onEntryRemove(entries, key) {
21
+ this.indexFor(entries).delete(key);
22
+ }
23
+ onEntriesClear() {
24
+ this.idleSenderKeys.clear();
25
+ this.idleDistributions.clear();
26
+ }
27
+ indexFor(entries) {
28
+ return entries === this.senderKeys ? this.idleSenderKeys : this.idleDistributions;
29
+ }
30
+ }
5
31
  /**
6
32
  * Read-through / write-through in-process cache for a persistent sender-key
7
- * backend. Reuses {@link SenderKeyMemoryStore} as the bounded-LRU L1 so the
33
+ * backend. Reuses {@link SenderKeyMemoryStore} as the bounded L1 so the
8
34
  * per-(group, sender) lookups repeated across a group fan-out skip the
9
35
  * backend round-trip.
10
36
  *
@@ -15,13 +41,16 @@ const sender_key_store_1 = require("../memory/sender-key.store");
15
41
  * (`deleteDeviceSenderKey`/`markForgetSenderKey`) run on the backend for the
16
42
  * authoritative count and then invalidate the matching L1 entries via the
17
43
  * memory store's own sweep. See {@link withSessionCache} for the shared
18
- * coherence model and the single-writer-per-session assumption.
44
+ * coherence model, the single-writer-per-session assumption and the eviction
45
+ * with and without `expiry`.
19
46
  */
20
- function withSenderKeyCache(backend, maxEntries) {
21
- const l1 = new sender_key_store_1.SenderKeyMemoryStore({
22
- maxSenderKeys: maxEntries,
23
- maxSenderDistributions: maxEntries
24
- });
47
+ function withSenderKeyCache(backend, maxEntries, expiry) {
48
+ const l1 = expiry
49
+ ? new IdleSenderKeyMemoryStore(maxEntries, expiry)
50
+ : new sender_key_store_1.SenderKeyMemoryStore({
51
+ maxSenderKeys: maxEntries,
52
+ maxSenderDistributions: maxEntries
53
+ });
25
54
  let generation = 0;
26
55
  return {
27
56
  upsertSenderKey: async (record) => {
@@ -97,6 +126,8 @@ function withSenderKeyCache(backend, maxEntries) {
97
126
  await l1.clear();
98
127
  },
99
128
  destroy: async () => {
129
+ if (l1 instanceof IdleSenderKeyMemoryStore)
130
+ l1.detach();
100
131
  await l1.clear();
101
132
  await backend.destroy?.();
102
133
  }
@@ -1,10 +1,13 @@
1
1
  import type { WaSessionStore } from '../contracts/session.store';
2
2
  import type { WithDestroyLifecycle } from '../types';
3
+ import { type IdleExpiry } from '../../util/collections';
3
4
  /**
4
5
  * Read-through / write-through in-process cache for a persistent session
5
- * backend. Reuses {@link WaSessionMemoryStore} as the bounded-LRU L1 so that
6
+ * backend. Reuses {@link WaSessionMemoryStore} as the bounded L1 so that
6
7
  * repeated reads of the same peer on the send/recv path skip the backend
7
- * round-trip.
8
+ * round-trip. Without `expiry` the cap evicts the least recently written
9
+ * entry; with it, the least recently used, and entries idle past the TTL
10
+ * leave the L1 (never the backend) and are re-read on next use.
8
11
  *
9
12
  * Coherence model (single process):
10
13
  * - every mutation (set/delete/clear) writes the backend then the L1 in
@@ -20,4 +23,4 @@ import type { WithDestroyLifecycle } from '../types';
20
23
  * session with the cache enabled - there is no cross-process invalidation
21
24
  * channel, so another process's writes would leave this L1 stale.
22
25
  */
23
- export declare function withSessionCache(backend: WaSessionStore, maxEntries?: number): WithDestroyLifecycle<WaSessionStore>;
26
+ export declare function withSessionCache(backend: WaSessionStore, maxEntries?: number, expiry?: IdleExpiry): WithDestroyLifecycle<WaSessionStore>;
@@ -2,11 +2,33 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.withSessionCache = withSessionCache;
4
4
  const session_store_1 = require("../memory/session.store");
5
+ const collections_1 = require("../../util/collections");
6
+ /** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
7
+ class IdleSessionMemoryStore extends session_store_1.WaSessionMemoryStore {
8
+ constructor(maxSessions, expiry) {
9
+ super({ maxSessions });
10
+ this.idle = new collections_1.IdleExpiryIndex(this.signalSessions, expiry);
11
+ }
12
+ detach() {
13
+ this.idle.detach();
14
+ }
15
+ onEntryAccess(key) {
16
+ this.idle.touch(key);
17
+ }
18
+ onEntryRemove(key) {
19
+ this.idle.delete(key);
20
+ }
21
+ onEntriesClear() {
22
+ this.idle.clear();
23
+ }
24
+ }
5
25
  /**
6
26
  * Read-through / write-through in-process cache for a persistent session
7
- * backend. Reuses {@link WaSessionMemoryStore} as the bounded-LRU L1 so that
27
+ * backend. Reuses {@link WaSessionMemoryStore} as the bounded L1 so that
8
28
  * repeated reads of the same peer on the send/recv path skip the backend
9
- * round-trip.
29
+ * round-trip. Without `expiry` the cap evicts the least recently written
30
+ * entry; with it, the least recently used, and entries idle past the TTL
31
+ * leave the L1 (never the backend) and are re-read on next use.
10
32
  *
11
33
  * Coherence model (single process):
12
34
  * - every mutation (set/delete/clear) writes the backend then the L1 in
@@ -22,8 +44,10 @@ const session_store_1 = require("../memory/session.store");
22
44
  * session with the cache enabled - there is no cross-process invalidation
23
45
  * channel, so another process's writes would leave this L1 stale.
24
46
  */
25
- function withSessionCache(backend, maxEntries) {
26
- const l1 = new session_store_1.WaSessionMemoryStore({ maxSessions: maxEntries });
47
+ function withSessionCache(backend, maxEntries, expiry) {
48
+ const l1 = expiry
49
+ ? new IdleSessionMemoryStore(maxEntries, expiry)
50
+ : new session_store_1.WaSessionMemoryStore({ maxSessions: maxEntries });
27
51
  let generation = 0;
28
52
  return {
29
53
  hasSession: async (address) => {
@@ -88,6 +112,8 @@ function withSessionCache(backend, maxEntries) {
88
112
  await l1.clear();
89
113
  },
90
114
  destroy: async () => {
115
+ if (l1 instanceof IdleSessionMemoryStore)
116
+ l1.detach();
91
117
  await l1.clear();
92
118
  await backend.destroy?.();
93
119
  }
@@ -38,6 +38,7 @@ const signal_store_1 = require("./memory/signal.store");
38
38
  const thread_store_1 = require("./memory/thread.store");
39
39
  const noop_store_1 = require("./noop.store");
40
40
  const coercion_1 = require("../util/coercion");
41
+ const collections_1 = require("../util/collections");
41
42
  const primitives_1 = require("../util/primitives");
42
43
  const DEFAULT_CACHE_TTLS_MS = Object.freeze({
43
44
  retryMs: 60 * 1000,
@@ -59,6 +60,8 @@ const REQUIRED_PROVIDER_DOMAINS = [
59
60
  'contacts',
60
61
  'privacyToken'
61
62
  ];
63
+ const CACHE_LAYER_DOMAINS = ['session', 'identity', 'senderKey', 'privacyToken'];
64
+ const MIN_CACHE_LAYER_TTL_MS = 1000;
62
65
  function hasDestroy(value) {
63
66
  return (!!value &&
64
67
  typeof value === 'object' &&
@@ -73,6 +76,37 @@ async function destroyIfSupported(value) {
73
76
  function usesBackend(provider) {
74
77
  return !!provider && provider !== 'memory' && provider !== 'none';
75
78
  }
79
+ /**
80
+ * Validates `cacheLayer.ttlMs` and hands each domain that gets an L1 its idle
81
+ * expiry, all on one clock. A TTL on a domain without an L1 is warned and dropped.
82
+ */
83
+ function resolveCacheLayerExpiry(cacheLayer, providers, logger) {
84
+ const ttls = [];
85
+ for (const domain of CACHE_LAYER_DOMAINS) {
86
+ const ttlMs = cacheLayer.ttlMs?.[`${domain}Ms`];
87
+ if (ttlMs === undefined)
88
+ continue;
89
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < MIN_CACHE_LAYER_TTL_MS) {
90
+ throw new Error(`cacheLayer.ttlMs.${domain}Ms must be a safe integer >= ${MIN_CACHE_LAYER_TTL_MS}`);
91
+ }
92
+ if (cacheLayer[domain] && usesBackend(providers[domain])) {
93
+ ttls.push({ domain, ttlMs });
94
+ continue;
95
+ }
96
+ logger?.warn('cacheLayer ttl ignored: domain has no L1', {
97
+ domain,
98
+ cached: cacheLayer[domain] === true,
99
+ provider: providers[domain] ?? 'memory'
100
+ });
101
+ }
102
+ const expiry = {};
103
+ if (ttls.length === 0)
104
+ return expiry;
105
+ const clock = new collections_1.IdleSweepClock((0, collections_1.resolveIdleSweepPeriodMs)(ttls.map((entry) => entry.ttlMs)));
106
+ for (const { domain, ttlMs } of ttls)
107
+ expiry[domain] = { clock, ttlMs };
108
+ return expiry;
109
+ }
76
110
  function resolveStore(sessionId, backends, provider, domain, kind, fallback) {
77
111
  if (!provider || provider === 'memory' || provider === 'none') {
78
112
  return fallback();
@@ -115,6 +149,7 @@ function createStore(options) {
115
149
  deviceList: (0, coercion_1.resolvePositive)(options.memory?.cacheTtlMs?.deviceListMs, DEFAULT_CACHE_TTLS_MS.deviceListMs, 'memory.cacheTtlMs.deviceListMs'),
116
150
  messageSecret: (0, coercion_1.resolvePositive)(options.memory?.cacheTtlMs?.messageSecretMs, DEFAULT_CACHE_TTLS_MS.messageSecretMs, 'memory.cacheTtlMs.messageSecretMs')
117
151
  });
152
+ const cacheLayerExpiry = resolveCacheLayerExpiry(cacheLayer, providers, storeLogger);
118
153
  const sessions = new Map();
119
154
  const pendingSessionDestroys = new Set();
120
155
  let storeDestroyed = false;
@@ -211,20 +246,20 @@ function createStore(options) {
211
246
  const signalStore = (0, signal_lock_1.withSignalLock)(rawSignal);
212
247
  const preKeyStore = (0, pre_key_lock_1.withPreKeyLock)(rawPreKey);
213
248
  const sessionStore = (0, session_lock_1.withSessionLock)(cacheLayer.session && usesBackend(providers.session)
214
- ? (0, session_cache_1.withSessionCache)(rawSession, cacheLayer.limits?.session)
249
+ ? (0, session_cache_1.withSessionCache)(rawSession, cacheLayer.limits?.session, cacheLayerExpiry.session)
215
250
  : rawSession);
216
251
  const identityStore = (0, identity_lock_1.withIdentityLock)(cacheLayer.identity && usesBackend(providers.identity)
217
- ? (0, identity_cache_1.withIdentityCache)(rawIdentity, cacheLayer.limits?.identity)
252
+ ? (0, identity_cache_1.withIdentityCache)(rawIdentity, cacheLayer.limits?.identity, cacheLayerExpiry.identity)
218
253
  : rawIdentity);
219
254
  const senderKeyStore = (0, sender_key_lock_1.withSenderKeyLock)(cacheLayer.senderKey && usesBackend(providers.senderKey)
220
- ? (0, sender_key_cache_1.withSenderKeyCache)(rawSenderKey, cacheLayer.limits?.senderKey)
255
+ ? (0, sender_key_cache_1.withSenderKeyCache)(rawSenderKey, cacheLayer.limits?.senderKey, cacheLayerExpiry.senderKey)
221
256
  : rawSenderKey);
222
257
  const appStateStore = (0, appstate_lock_1.withAppStateLock)(rawAppState);
223
258
  const messageStore = (0, message_lock_1.withMessageLock)(rawMessages);
224
259
  const threadStore = (0, thread_lock_1.withThreadLock)(rawThreads);
225
260
  const contactStore = (0, contact_lock_1.withContactLock)(rawContacts);
226
261
  const privacyTokenStore = (0, privacy_token_lock_1.withPrivacyTokenLock)(cacheLayer.privacyToken && usesBackend(providers.privacyToken)
227
- ? (0, privacy_token_cache_1.withPrivacyTokenCache)(rawPrivacyToken, cacheLayer.limits?.privacyToken)
262
+ ? (0, privacy_token_cache_1.withPrivacyTokenCache)(rawPrivacyToken, cacheLayer.limits?.privacyToken, cacheLayerExpiry.privacyToken)
228
263
  : rawPrivacyToken);
229
264
  let sessionDestroyed = false;
230
265
  let destroyPromise = null;
@@ -4,7 +4,7 @@ export interface WaIdentityMemoryStoreOptions {
4
4
  readonly maxRemoteIdentities?: number;
5
5
  }
6
6
  export declare class WaIdentityMemoryStore implements WaIdentityStoreContract {
7
- private readonly remoteIdentities;
7
+ protected readonly remoteIdentities: Map<string, Uint8Array>;
8
8
  private readonly maxRemoteIdentities;
9
9
  constructor(options?: WaIdentityMemoryStoreOptions);
10
10
  getRemoteIdentity(address: SignalAddress): Promise<Uint8Array | null>;
@@ -15,4 +15,10 @@ export declare class WaIdentityMemoryStore implements WaIdentityStoreContract {
15
15
  readonly identityKey: Uint8Array;
16
16
  }[]): Promise<void>;
17
17
  clear(): Promise<void>;
18
+ /** Runs with the key of every read hit and write. No-op unless a subclass tracks access. */
19
+ protected onEntryAccess(key: string): void;
20
+ /** Runs with the key of every cap-evicted entry. */
21
+ protected onEntryRemove(key: string): void;
22
+ protected onEntriesClear(): void;
23
+ private readonly onEvict;
18
24
  }
@@ -7,29 +7,53 @@ const collections_1 = require("../../util/collections");
7
7
  const DEFAULT_MAX_REMOTE_IDENTITIES = 8192;
8
8
  class WaIdentityMemoryStore {
9
9
  constructor(options = {}) {
10
+ this.onEvict = (key) => this.onEntryRemove(key);
10
11
  this.remoteIdentities = new Map();
11
12
  this.maxRemoteIdentities = (0, coercion_1.resolvePositive)(options.maxRemoteIdentities, DEFAULT_MAX_REMOTE_IDENTITIES, 'WaIdentityMemoryStoreOptions.maxRemoteIdentities');
12
13
  }
13
14
  async getRemoteIdentity(address) {
14
- return this.remoteIdentities.get((0, jid_1.signalAddressKey)(address)) ?? null;
15
+ const key = (0, jid_1.signalAddressKey)(address);
16
+ const identityKey = this.remoteIdentities.get(key);
17
+ if (identityKey === undefined)
18
+ return null;
19
+ this.onEntryAccess(key);
20
+ return identityKey;
15
21
  }
16
22
  async getRemoteIdentities(addresses) {
17
23
  const result = new Array(addresses.length);
18
24
  for (let i = 0; i < addresses.length; i += 1) {
19
- result[i] = this.remoteIdentities.get((0, jid_1.signalAddressKey)(addresses[i])) ?? null;
25
+ const key = (0, jid_1.signalAddressKey)(addresses[i]);
26
+ const identityKey = this.remoteIdentities.get(key);
27
+ if (identityKey === undefined) {
28
+ result[i] = null;
29
+ }
30
+ else {
31
+ this.onEntryAccess(key);
32
+ result[i] = identityKey;
33
+ }
20
34
  }
21
35
  return result;
22
36
  }
23
37
  async setRemoteIdentity(address, identityKey) {
24
- (0, collections_1.setBoundedMapEntry)(this.remoteIdentities, (0, jid_1.signalAddressKey)(address), identityKey, this.maxRemoteIdentities);
38
+ const key = (0, jid_1.signalAddressKey)(address);
39
+ (0, collections_1.setBoundedMapEntry)(this.remoteIdentities, key, identityKey, this.maxRemoteIdentities, this.onEvict);
40
+ this.onEntryAccess(key);
25
41
  }
26
42
  async setRemoteIdentities(entries) {
27
43
  for (const entry of entries) {
28
- (0, collections_1.setBoundedMapEntry)(this.remoteIdentities, (0, jid_1.signalAddressKey)(entry.address), entry.identityKey, this.maxRemoteIdentities);
44
+ const key = (0, jid_1.signalAddressKey)(entry.address);
45
+ (0, collections_1.setBoundedMapEntry)(this.remoteIdentities, key, entry.identityKey, this.maxRemoteIdentities, this.onEvict);
46
+ this.onEntryAccess(key);
29
47
  }
30
48
  }
31
49
  async clear() {
32
50
  this.remoteIdentities.clear();
51
+ this.onEntriesClear();
33
52
  }
53
+ /** Runs with the key of every read hit and write. No-op unless a subclass tracks access. */
54
+ onEntryAccess(key) { }
55
+ /** Runs with the key of every cap-evicted entry. */
56
+ onEntryRemove(key) { }
57
+ onEntriesClear() { }
34
58
  }
35
59
  exports.WaIdentityMemoryStore = WaIdentityMemoryStore;