zapo-js 1.9.0 → 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.
- package/README.md +14 -13
- package/dist/client/WaClient.d.ts +4 -0
- package/dist/client/WaClient.js +5 -1
- package/dist/client/coordinators/WaMessageCoordinator.d.ts +11 -4
- package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +11 -0
- package/dist/client/coordinators/WaMessageDispatchCoordinator.js +15 -5
- package/dist/client/messaging/ignore-key.d.ts +2 -1
- package/dist/client/messaging/ignore-key.js +24 -3
- package/dist/esm/client/WaClient.js +5 -1
- package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -5
- package/dist/esm/client/messaging/ignore-key.js +25 -4
- package/dist/esm/store/cache/identity.cache.js +30 -5
- package/dist/esm/store/cache/privacy-token.cache.js +30 -5
- package/dist/esm/store/cache/sender-key.cache.js +38 -7
- package/dist/esm/store/cache/session.cache.js +30 -4
- package/dist/esm/store/createStore.js +39 -4
- package/dist/esm/store/memory/identity.store.js +28 -4
- package/dist/esm/store/memory/privacy-token.store.js +24 -7
- package/dist/esm/store/memory/sender-key.store.js +36 -7
- package/dist/esm/store/memory/session.store.js +41 -7
- package/dist/esm/util/collections.js +94 -0
- package/dist/esm/util/index.js +2 -2
- package/dist/message/primitives/media-retry.d.ts +11 -0
- package/dist/store/cache/identity.cache.d.ts +6 -4
- package/dist/store/cache/identity.cache.js +30 -5
- package/dist/store/cache/privacy-token.cache.d.ts +6 -4
- package/dist/store/cache/privacy-token.cache.js +30 -5
- package/dist/store/cache/sender-key.cache.d.ts +5 -3
- package/dist/store/cache/sender-key.cache.js +38 -7
- package/dist/store/cache/session.cache.d.ts +6 -3
- package/dist/store/cache/session.cache.js +30 -4
- package/dist/store/createStore.js +39 -4
- package/dist/store/memory/identity.store.d.ts +7 -1
- package/dist/store/memory/identity.store.js +28 -4
- package/dist/store/memory/privacy-token.store.d.ts +7 -1
- package/dist/store/memory/privacy-token.store.js +24 -7
- package/dist/store/memory/sender-key.store.d.ts +12 -2
- package/dist/store/memory/sender-key.store.js +36 -7
- package/dist/store/memory/session.store.d.ts +7 -1
- package/dist/store/memory/session.store.js +41 -7
- package/dist/store/types.d.ts +43 -4
- package/dist/util/collections.d.ts +46 -0
- package/dist/util/collections.js +98 -0
- package/dist/util/index.d.ts +2 -2
- package/dist/util/index.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -127,19 +127,20 @@ The core lives at the repo root (`zapo-js`). Optional packages live in
|
|
|
127
127
|
[`packages/`](packages/) and ship under the `@zapo-js/*` scope. Install
|
|
128
128
|
only what you need.
|
|
129
129
|
|
|
130
|
-
| Package | Peer dependency
|
|
131
|
-
| ---------------------------------------------------- |
|
|
132
|
-
| [`@zapo-js/store-sqlite`](packages/store-sqlite) | `better-sqlite3`
|
|
133
|
-
| [`@zapo-js/store-redis`](packages/store-redis) | `ioredis`
|
|
134
|
-
| [`@zapo-js/store-mongo`](packages/store-mongo) | `mongodb`
|
|
135
|
-
| [`@zapo-js/store-mysql`](packages/store-mysql) | `mysql2`
|
|
136
|
-
| [`@zapo-js/store-postgres`](packages/store-postgres) | `pg`
|
|
137
|
-
| [`@zapo-js/media-utils`](packages/media-utils) | `sharp` + `file-type` + `ffmpeg`
|
|
138
|
-
| [`@zapo-js/native`](packages/native) | (none)
|
|
139
|
-
| [`@zapo-js/voip`](packages/voip) | `@roamhq/wrtc` + `libmlow-wasm`
|
|
140
|
-
| [`@zapo-js/
|
|
141
|
-
| [`@zapo-js/
|
|
142
|
-
| [`@zapo-js/
|
|
130
|
+
| Package | Peer dependency | Purpose |
|
|
131
|
+
| ---------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
132
|
+
| [`@zapo-js/store-sqlite`](packages/store-sqlite) | `better-sqlite3` | SQLite persistent store (single-process bots, dev sessions, small-to-medium prod). |
|
|
133
|
+
| [`@zapo-js/store-redis`](packages/store-redis) | `ioredis` | Redis-backed store with native TTL eviction. |
|
|
134
|
+
| [`@zapo-js/store-mongo`](packages/store-mongo) | `mongodb` | MongoDB-backed store with TTL-index eviction. |
|
|
135
|
+
| [`@zapo-js/store-mysql`](packages/store-mysql) | `mysql2` | MySQL / MariaDB-backed store with background cleanup poller. |
|
|
136
|
+
| [`@zapo-js/store-postgres`](packages/store-postgres) | `pg` | PostgreSQL-backed store with background cleanup poller. |
|
|
137
|
+
| [`@zapo-js/media-utils`](packages/media-utils) | `sharp` + `file-type` + `ffmpeg` | `WaMediaProcessor`: thumbnails, waveforms, voice-note normalization. |
|
|
138
|
+
| [`@zapo-js/native`](packages/native) | (none) | Rust crypto accelerator for the messaging hot path (X25519 ECDH, XEdDSA sign / verify). Auto-detected once installed; ships as a WASM build. |
|
|
139
|
+
| [`@zapo-js/voip`](packages/voip) | `@roamhq/wrtc` + `libmlow-wasm-fork` | `client.voip` plugin for calls: MLow/WASM codec, SRTP, STUN, WebRTC/SCTP relay transport. |
|
|
140
|
+
| [`@zapo-js/voip-media`](packages/voip-media) | `libmlow-wasm-fork` (optional, to carry media) + `@roamhq/wrtc` (optional, Node host) | Call media plane with no Node or `zapo-js` dependency, so it runs in the browser: relay transport, STUN, SRTP, RTP/RTCP, MLow codec. |
|
|
141
|
+
| [`@zapo-js/wam`](packages/wam) | (none) | `client.wam` plugin emitting the client-side WAM telemetry batches WA Web sends, for wire parity and anti-fingerprinting. |
|
|
142
|
+
| [`@zapo-js/fake-server`](packages/fake-server) | (none) | In-process fake WhatsApp Web server for end-to-end testing. |
|
|
143
|
+
| [`@zapo-js/mcp-server`](packages/mcp-server) | `@modelcontextprotocol/sdk` | **Dev-only.** MCP server exposing multi-session `WaClient`s as dynamic tools for an LLM agent (Claude Code / Cursor / etc.). Not for production. |
|
|
143
144
|
|
|
144
145
|
Each package's README has the install + config + integration notes.
|
|
145
146
|
|
|
@@ -132,6 +132,10 @@ declare class WaClientImpl extends EventEmitter {
|
|
|
132
132
|
* arrays are empty. The predicate receives a parsed
|
|
133
133
|
* {@link WaIgnoreKeyContext} and returns `true` to drop the stanza.
|
|
134
134
|
*
|
|
135
|
+
* `category='peer'` messages from our own account (app-state key share,
|
|
136
|
+
* history sync, PDO responses) are never dropped: they are protocol
|
|
137
|
+
* traffic between our own devices, not chat messages.
|
|
138
|
+
*
|
|
135
139
|
* @example
|
|
136
140
|
* ```ts
|
|
137
141
|
* client.ignoreKey({ remoteJid: spammerJid })
|
package/dist/client/WaClient.js
CHANGED
|
@@ -458,6 +458,10 @@ class WaClientImpl extends node_events_1.EventEmitter {
|
|
|
458
458
|
* arrays are empty. The predicate receives a parsed
|
|
459
459
|
* {@link WaIgnoreKeyContext} and returns `true` to drop the stanza.
|
|
460
460
|
*
|
|
461
|
+
* `category='peer'` messages from our own account (app-state key share,
|
|
462
|
+
* history sync, PDO responses) are never dropped: they are protocol
|
|
463
|
+
* traffic between our own devices, not chat messages.
|
|
464
|
+
*
|
|
461
465
|
* @example
|
|
462
466
|
* ```ts
|
|
463
467
|
* client.ignoreKey({ remoteJid: spammerJid })
|
|
@@ -469,7 +473,7 @@ class WaClientImpl extends node_events_1.EventEmitter {
|
|
|
469
473
|
if (typeof input !== 'function') {
|
|
470
474
|
(0, ignore_key_1.validateIgnoreKey)(input);
|
|
471
475
|
}
|
|
472
|
-
const filter = (0, ignore_key_1.createIgnoreKeyFilter)(input, () => this.deps.authClient.getCurrentCredentials()
|
|
476
|
+
const filter = (0, ignore_key_1.createIgnoreKeyFilter)(input, () => this.deps.authClient.getCurrentCredentials());
|
|
473
477
|
return this.deps.incomingNode.registerIncomingStanzaFilter(filter);
|
|
474
478
|
}
|
|
475
479
|
/** Auth client: pairing, credentials, registration state. */
|
|
@@ -384,10 +384,17 @@ export declare class WaMessageCoordinator {
|
|
|
384
384
|
* the sealed request and resolves once the server answers with a
|
|
385
385
|
* `mediaretry` notification, usually within a couple of seconds.
|
|
386
386
|
*
|
|
387
|
-
* On `result: 'success'`
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
387
|
+
* On `result: 'success'` the media key, hashes, and length of the original
|
|
388
|
+
* message normally stay valid, so patch the fresh `directPath` into the
|
|
389
|
+
* message and download again. A second call for a message already in
|
|
390
|
+
* flight joins the first request instead of sending another receipt.
|
|
391
|
+
*
|
|
392
|
+
* A `success` is not a guarantee that the original key still opens the
|
|
393
|
+
* blob. Some primaries re-encrypt the file on every re-upload, and the
|
|
394
|
+
* re-served bytes then fail `fileEncSha256` and make `downloadBytes()`
|
|
395
|
+
* throw a MAC mismatch. Nothing in the round-trip carries key material for
|
|
396
|
+
* the new ciphertext, so the message cannot be recovered through this API
|
|
397
|
+
* and requesting another reupload will not help.
|
|
391
398
|
*
|
|
392
399
|
* The other three `result` values are answers too, not thrown errors:
|
|
393
400
|
* `not_found` means the sender no longer holds the file and nothing can
|
|
@@ -7,6 +7,7 @@ import type { WaGroupEvent, WaOutgoingMessageEvent, WaSendMessageOptions, WaSign
|
|
|
7
7
|
import type { Logger } from '../../infra/log/types';
|
|
8
8
|
import { type WaSendContextInfo } from '../../message/context-info';
|
|
9
9
|
import { type IcdcMeta } from '../../message/crypto/icdc';
|
|
10
|
+
import { type WaOutboundMessageAttrs } from '../../message/encode/content';
|
|
10
11
|
import type { WaEncryptedMessageInput, WaMessageBuildResult, WaMessagePublishOptions, WaMessagePublishResult, WaSendMessageContent, WaSendReceiptInput } from '../../message/types';
|
|
11
12
|
import type { WaMessageClient } from '../../message/WaMessageClient';
|
|
12
13
|
import { type Proto } from '../../proto';
|
|
@@ -96,6 +97,16 @@ export interface WaGroupHistoryAudience {
|
|
|
96
97
|
* while nobody received anything.
|
|
97
98
|
*/
|
|
98
99
|
export declare function restrictGroupHistoryTargets(message: Proto.IMessage, participantUserJids: readonly string[], senderJid: string, onDropped?: (droppedJids: readonly string[], totalExpected: number) => void): readonly string[];
|
|
100
|
+
/**
|
|
101
|
+
* Resolves the stanza `type` and `enc.mediatype` of an outbound message. A
|
|
102
|
+
* `<biz>` companion rides a type=text stanza with no mediatype, except on a
|
|
103
|
+
* group list: there the server nacks with 479 (SMAX_INVALID) unless
|
|
104
|
+
* type=media, mediatype=list and `<biz><list>` travel together.
|
|
105
|
+
*/
|
|
106
|
+
export declare function resolveOutboundStanzaShape(attrs: Pick<WaOutboundMessageAttrs, 'buttonAddonKind' | 'typeAttr' | 'mediatype'>, isGroup: boolean): {
|
|
107
|
+
readonly type: string;
|
|
108
|
+
readonly mediatype: string | undefined;
|
|
109
|
+
};
|
|
99
110
|
export declare class WaMessageDispatchCoordinator {
|
|
100
111
|
private readonly deps;
|
|
101
112
|
private readonly mobileMessageIdFormat;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.WaMessageDispatchCoordinator = void 0;
|
|
4
4
|
exports.restrictGroupHistoryTargets = restrictGroupHistoryTargets;
|
|
5
|
+
exports.resolveOutboundStanzaShape = resolveOutboundStanzaShape;
|
|
5
6
|
const _crypto_1 = require("../../crypto");
|
|
6
7
|
const primitives_1 = require("../../crypto/core/primitives");
|
|
7
8
|
const PromiseDedup_1 = require("../../infra/perf/PromiseDedup");
|
|
@@ -73,6 +74,19 @@ function restrictGroupHistoryTargets(message, participantUserJids, senderJid, on
|
|
|
73
74
|
}
|
|
74
75
|
return targets;
|
|
75
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolves the stanza `type` and `enc.mediatype` of an outbound message. A
|
|
79
|
+
* `<biz>` companion rides a type=text stanza with no mediatype, except on a
|
|
80
|
+
* group list: there the server nacks with 479 (SMAX_INVALID) unless
|
|
81
|
+
* type=media, mediatype=list and `<biz><list>` travel together.
|
|
82
|
+
*/
|
|
83
|
+
function resolveOutboundStanzaShape(attrs, isGroup) {
|
|
84
|
+
const flattensForAddon = attrs.buttonAddonKind !== null && !(isGroup && attrs.buttonAddonKind === 'list');
|
|
85
|
+
if (flattensForAddon) {
|
|
86
|
+
return { type: 'text', mediatype: undefined };
|
|
87
|
+
}
|
|
88
|
+
return { type: attrs.typeAttr, mediatype: attrs.mediatype ?? undefined };
|
|
89
|
+
}
|
|
76
90
|
class WaMessageDispatchCoordinator {
|
|
77
91
|
constructor(options) {
|
|
78
92
|
this.icdcDedup = new PromiseDedup_1.PromiseDedup();
|
|
@@ -403,12 +417,8 @@ class WaMessageDispatchCoordinator {
|
|
|
403
417
|
const outboundAttrs = (0, content_1.resolveOutboundMessageAttrs)(messageWithIcdc);
|
|
404
418
|
const buttonAddonKind = outboundAttrs.buttonAddonKind;
|
|
405
419
|
const buttonAddonNode = buttonAddonKind ? (0, message_1.buildButtonAddonNode)(buttonAddonKind) : undefined;
|
|
406
|
-
|
|
407
|
-
// omit enc.mediatype; sending type=media + mediatype=list/button alongside the
|
|
408
|
-
// companion is rejected by the server as SMAX_INVALID (479).
|
|
409
|
-
const type = buttonAddonKind ? 'text' : outboundAttrs.typeAttr;
|
|
420
|
+
const { type, mediatype } = resolveOutboundStanzaShape(outboundAttrs, isGroup);
|
|
410
421
|
const edit = outboundAttrs.edit ?? undefined;
|
|
411
|
-
const mediatype = buttonAddonKind ? undefined : (outboundAttrs.mediatype ?? undefined);
|
|
412
422
|
const metaAttrs = outboundAttrs.metaAttrs;
|
|
413
423
|
const metaNode = metaAttrs ? (0, message_1.buildMetaNode)(metaAttrs) : undefined;
|
|
414
424
|
const customNodes = [];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { WaAuthCredentials } from '../../auth/types';
|
|
1
2
|
import type { WaIgnoreKey, WaIgnoreKeyContext, WaIgnoreKeyPredicate, WaIncomingStanzaFilter } from '../types';
|
|
2
3
|
import type { BinaryNode } from '../../transport/types';
|
|
3
4
|
export declare function validateIgnoreKey(d: WaIgnoreKey): void;
|
|
@@ -8,4 +9,4 @@ export declare function validateIgnoreKey(d: WaIgnoreKey): void;
|
|
|
8
9
|
export declare function extractIgnoreKeyContext(node: BinaryNode, meJid: string | null | undefined): WaIgnoreKeyContext | null;
|
|
9
10
|
/** Pure matcher. Exported for direct testing without a coordinator. */
|
|
10
11
|
export declare function matchesIgnoreKey(node: BinaryNode, d: WaIgnoreKey, meJid: string | null | undefined): boolean;
|
|
11
|
-
export declare function createIgnoreKeyFilter(input: WaIgnoreKey | WaIgnoreKeyPredicate,
|
|
12
|
+
export declare function createIgnoreKeyFilter(input: WaIgnoreKey | WaIgnoreKeyPredicate, getMe: () => Pick<WaAuthCredentials, 'meJid' | 'meLid'> | null | undefined): WaIncomingStanzaFilter;
|
|
@@ -123,12 +123,33 @@ function matchesIgnoreKey(node, d, meJid) {
|
|
|
123
123
|
return false;
|
|
124
124
|
return true;
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
/**
|
|
127
|
+
* A `category='peer'` message from our own account is traffic between our own
|
|
128
|
+
* devices (app-state key share, history sync, PDO responses), not a chat
|
|
129
|
+
* message, so it has no key to ignore by. Dropping it starves the protocol:
|
|
130
|
+
* app-state collections stay blocked on a key whose share never gets through.
|
|
131
|
+
* The own-account check keeps a foreign sender from dodging a filter by
|
|
132
|
+
* stamping the attribute.
|
|
133
|
+
*/
|
|
134
|
+
function isOwnPeerMessage(node, me) {
|
|
135
|
+
if (node.tag !== message_1.WA_MESSAGE_TAGS.MESSAGE || node.attrs.category !== 'peer' || !me) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const from = node.attrs.from;
|
|
139
|
+
return !!from && tryParseJid(from) !== null && (0, jid_1.isOwnAccountJid)(from, me.meJid, me.meLid);
|
|
140
|
+
}
|
|
141
|
+
function createIgnoreKeyFilter(input, getMe) {
|
|
127
142
|
if (typeof input === 'function') {
|
|
128
143
|
return (node) => {
|
|
129
|
-
const
|
|
144
|
+
const me = getMe();
|
|
145
|
+
if (isOwnPeerMessage(node, me))
|
|
146
|
+
return false;
|
|
147
|
+
const ctx = extractIgnoreKeyContext(node, me?.meJid);
|
|
130
148
|
return ctx !== null && input(ctx);
|
|
131
149
|
};
|
|
132
150
|
}
|
|
133
|
-
return (node) =>
|
|
151
|
+
return (node) => {
|
|
152
|
+
const me = getMe();
|
|
153
|
+
return !isOwnPeerMessage(node, me) && matchesIgnoreKey(node, input, me?.meJid);
|
|
154
|
+
};
|
|
134
155
|
}
|
|
@@ -455,6 +455,10 @@ class WaClientImpl extends EventEmitter {
|
|
|
455
455
|
* arrays are empty. The predicate receives a parsed
|
|
456
456
|
* {@link WaIgnoreKeyContext} and returns `true` to drop the stanza.
|
|
457
457
|
*
|
|
458
|
+
* `category='peer'` messages from our own account (app-state key share,
|
|
459
|
+
* history sync, PDO responses) are never dropped: they are protocol
|
|
460
|
+
* traffic between our own devices, not chat messages.
|
|
461
|
+
*
|
|
458
462
|
* @example
|
|
459
463
|
* ```ts
|
|
460
464
|
* client.ignoreKey({ remoteJid: spammerJid })
|
|
@@ -466,7 +470,7 @@ class WaClientImpl extends EventEmitter {
|
|
|
466
470
|
if (typeof input !== 'function') {
|
|
467
471
|
validateIgnoreKey(input);
|
|
468
472
|
}
|
|
469
|
-
const filter = createIgnoreKeyFilter(input, () => this.deps.authClient.getCurrentCredentials()
|
|
473
|
+
const filter = createIgnoreKeyFilter(input, () => this.deps.authClient.getCurrentCredentials());
|
|
470
474
|
return this.deps.incomingNode.registerIncomingStanzaFilter(filter);
|
|
471
475
|
}
|
|
472
476
|
/** Auth client: pairing, credentials, registration state. */
|
|
@@ -69,6 +69,19 @@ export function restrictGroupHistoryTargets(message, participantUserJids, sender
|
|
|
69
69
|
}
|
|
70
70
|
return targets;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolves the stanza `type` and `enc.mediatype` of an outbound message. A
|
|
74
|
+
* `<biz>` companion rides a type=text stanza with no mediatype, except on a
|
|
75
|
+
* group list: there the server nacks with 479 (SMAX_INVALID) unless
|
|
76
|
+
* type=media, mediatype=list and `<biz><list>` travel together.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveOutboundStanzaShape(attrs, isGroup) {
|
|
79
|
+
const flattensForAddon = attrs.buttonAddonKind !== null && !(isGroup && attrs.buttonAddonKind === 'list');
|
|
80
|
+
if (flattensForAddon) {
|
|
81
|
+
return { type: 'text', mediatype: undefined };
|
|
82
|
+
}
|
|
83
|
+
return { type: attrs.typeAttr, mediatype: attrs.mediatype ?? undefined };
|
|
84
|
+
}
|
|
72
85
|
export class WaMessageDispatchCoordinator {
|
|
73
86
|
constructor(options) {
|
|
74
87
|
this.icdcDedup = new PromiseDedup();
|
|
@@ -399,12 +412,8 @@ export class WaMessageDispatchCoordinator {
|
|
|
399
412
|
const outboundAttrs = resolveOutboundMessageAttrs(messageWithIcdc);
|
|
400
413
|
const buttonAddonKind = outboundAttrs.buttonAddonKind;
|
|
401
414
|
const buttonAddonNode = buttonAddonKind ? buildButtonAddonNode(buttonAddonKind) : undefined;
|
|
402
|
-
|
|
403
|
-
// omit enc.mediatype; sending type=media + mediatype=list/button alongside the
|
|
404
|
-
// companion is rejected by the server as SMAX_INVALID (479).
|
|
405
|
-
const type = buttonAddonKind ? 'text' : outboundAttrs.typeAttr;
|
|
415
|
+
const { type, mediatype } = resolveOutboundStanzaShape(outboundAttrs, isGroup);
|
|
406
416
|
const edit = outboundAttrs.edit ?? undefined;
|
|
407
|
-
const mediatype = buttonAddonKind ? undefined : (outboundAttrs.mediatype ?? undefined);
|
|
408
417
|
const metaAttrs = outboundAttrs.metaAttrs;
|
|
409
418
|
const metaNode = metaAttrs ? buildMetaNode(metaAttrs) : undefined;
|
|
410
419
|
const customNodes = [];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseJidFull } from '../../protocol/jid.js';
|
|
1
|
+
import { isOwnAccountJid, parseJidFull } from '../../protocol/jid.js';
|
|
2
2
|
import { WA_MESSAGE_TAGS } from '../../protocol/message.js';
|
|
3
3
|
import { WA_NODE_TAGS } from '../../protocol/nodes.js';
|
|
4
4
|
const TAG_TO_KIND = {
|
|
@@ -117,12 +117,33 @@ export function matchesIgnoreKey(node, d, meJid) {
|
|
|
117
117
|
return false;
|
|
118
118
|
return true;
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
/**
|
|
121
|
+
* A `category='peer'` message from our own account is traffic between our own
|
|
122
|
+
* devices (app-state key share, history sync, PDO responses), not a chat
|
|
123
|
+
* message, so it has no key to ignore by. Dropping it starves the protocol:
|
|
124
|
+
* app-state collections stay blocked on a key whose share never gets through.
|
|
125
|
+
* The own-account check keeps a foreign sender from dodging a filter by
|
|
126
|
+
* stamping the attribute.
|
|
127
|
+
*/
|
|
128
|
+
function isOwnPeerMessage(node, me) {
|
|
129
|
+
if (node.tag !== WA_MESSAGE_TAGS.MESSAGE || node.attrs.category !== 'peer' || !me) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const from = node.attrs.from;
|
|
133
|
+
return !!from && tryParseJid(from) !== null && isOwnAccountJid(from, me.meJid, me.meLid);
|
|
134
|
+
}
|
|
135
|
+
export function createIgnoreKeyFilter(input, getMe) {
|
|
121
136
|
if (typeof input === 'function') {
|
|
122
137
|
return (node) => {
|
|
123
|
-
const
|
|
138
|
+
const me = getMe();
|
|
139
|
+
if (isOwnPeerMessage(node, me))
|
|
140
|
+
return false;
|
|
141
|
+
const ctx = extractIgnoreKeyContext(node, me?.meJid);
|
|
124
142
|
return ctx !== null && input(ctx);
|
|
125
143
|
};
|
|
126
144
|
}
|
|
127
|
-
return (node) =>
|
|
145
|
+
return (node) => {
|
|
146
|
+
const me = getMe();
|
|
147
|
+
return !isOwnPeerMessage(node, me) && matchesIgnoreKey(node, input, me?.meJid);
|
|
148
|
+
};
|
|
128
149
|
}
|
|
@@ -1,18 +1,41 @@
|
|
|
1
1
|
import { WaIdentityMemoryStore } from '../memory/identity.store.js';
|
|
2
|
+
import { IdleExpiryIndex } from '../../util/collections.js';
|
|
3
|
+
/** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
|
|
4
|
+
class IdleIdentityMemoryStore extends WaIdentityMemoryStore {
|
|
5
|
+
constructor(maxRemoteIdentities, expiry) {
|
|
6
|
+
super({ maxRemoteIdentities });
|
|
7
|
+
this.idle = new IdleExpiryIndex(this.remoteIdentities, expiry);
|
|
8
|
+
}
|
|
9
|
+
detach() {
|
|
10
|
+
this.idle.detach();
|
|
11
|
+
}
|
|
12
|
+
onEntryAccess(key) {
|
|
13
|
+
this.idle.touch(key);
|
|
14
|
+
}
|
|
15
|
+
onEntryRemove(key) {
|
|
16
|
+
this.idle.delete(key);
|
|
17
|
+
}
|
|
18
|
+
onEntriesClear() {
|
|
19
|
+
this.idle.clear();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
2
22
|
/**
|
|
3
23
|
* Read-through / write-through in-process cache for a persistent remote
|
|
4
|
-
* identity backend. Reuses {@link WaIdentityMemoryStore} as the bounded
|
|
5
|
-
*
|
|
24
|
+
* identity backend. Reuses {@link WaIdentityMemoryStore} as the bounded L1.
|
|
25
|
+
* Remote identities are read alongside sessions on the send path (the
|
|
6
26
|
* identity-mismatch guard), so caching them complements
|
|
7
|
-
* {@link withSessionCache}
|
|
27
|
+
* {@link withSessionCache}, which also describes the eviction with and
|
|
28
|
+
* without `expiry`.
|
|
8
29
|
*
|
|
9
30
|
* The identity store has no per-key delete: identities are overwritten on
|
|
10
31
|
* re-establishment, so a peer's key change propagates through the
|
|
11
32
|
* write-through `setRemoteIdentity`. See {@link withSessionCache} for the
|
|
12
33
|
* shared coherence model and the single-writer-per-session assumption.
|
|
13
34
|
*/
|
|
14
|
-
export function withIdentityCache(backend, maxEntries) {
|
|
15
|
-
const l1 =
|
|
35
|
+
export function withIdentityCache(backend, maxEntries, expiry) {
|
|
36
|
+
const l1 = expiry
|
|
37
|
+
? new IdleIdentityMemoryStore(maxEntries, expiry)
|
|
38
|
+
: new WaIdentityMemoryStore({ maxRemoteIdentities: maxEntries });
|
|
16
39
|
let generation = 0;
|
|
17
40
|
return {
|
|
18
41
|
getRemoteIdentity: async (address) => {
|
|
@@ -68,6 +91,8 @@ export function withIdentityCache(backend, maxEntries) {
|
|
|
68
91
|
await l1.clear();
|
|
69
92
|
},
|
|
70
93
|
destroy: async () => {
|
|
94
|
+
if (l1 instanceof IdleIdentityMemoryStore)
|
|
95
|
+
l1.detach();
|
|
71
96
|
await l1.clear();
|
|
72
97
|
await backend.destroy?.();
|
|
73
98
|
}
|
|
@@ -1,18 +1,41 @@
|
|
|
1
1
|
import { WaPrivacyTokenMemoryStore } from '../memory/privacy-token.store.js';
|
|
2
|
+
import { IdleExpiryIndex } from '../../util/collections.js';
|
|
3
|
+
/** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
|
|
4
|
+
class IdlePrivacyTokenMemoryStore extends WaPrivacyTokenMemoryStore {
|
|
5
|
+
constructor(maxEntries, expiry) {
|
|
6
|
+
super(maxEntries);
|
|
7
|
+
this.idle = new IdleExpiryIndex(this.records, expiry);
|
|
8
|
+
}
|
|
9
|
+
detach() {
|
|
10
|
+
this.idle.detach();
|
|
11
|
+
}
|
|
12
|
+
onEntryAccess(jid) {
|
|
13
|
+
this.idle.touch(jid);
|
|
14
|
+
}
|
|
15
|
+
onEntryRemove(jid) {
|
|
16
|
+
this.idle.delete(jid);
|
|
17
|
+
}
|
|
18
|
+
onEntriesClear() {
|
|
19
|
+
this.idle.clear();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
2
22
|
/**
|
|
3
23
|
* Read-through cache for a persistent privacy-token backend. Reuses
|
|
4
|
-
* {@link WaPrivacyTokenMemoryStore} as the bounded
|
|
24
|
+
* {@link WaPrivacyTokenMemoryStore} as the bounded L1.
|
|
5
25
|
*
|
|
6
26
|
* Unlike the signal caches this is **invalidate-on-write, not
|
|
7
27
|
* write-through**: `upsert` merges partial fields into the existing row on
|
|
8
28
|
* the backend, so caching the partial incoming record would diverge from the
|
|
9
29
|
* backend's merged result. Instead each upsert drops the L1 entry and the
|
|
10
30
|
* next read re-populates from the merged backend truth. See
|
|
11
|
-
* {@link withSessionCache} for the shared coherence model
|
|
12
|
-
* single-writer-per-session assumption
|
|
31
|
+
* {@link withSessionCache} for the shared coherence model, the
|
|
32
|
+
* single-writer-per-session assumption and the eviction with and without
|
|
33
|
+
* `expiry`.
|
|
13
34
|
*/
|
|
14
|
-
export function withPrivacyTokenCache(backend, maxEntries) {
|
|
15
|
-
const l1 =
|
|
35
|
+
export function withPrivacyTokenCache(backend, maxEntries, expiry) {
|
|
36
|
+
const l1 = expiry
|
|
37
|
+
? new IdlePrivacyTokenMemoryStore(maxEntries, expiry)
|
|
38
|
+
: new WaPrivacyTokenMemoryStore(maxEntries);
|
|
16
39
|
let generation = 0;
|
|
17
40
|
return {
|
|
18
41
|
upsert: async (record) => {
|
|
@@ -50,6 +73,8 @@ export function withPrivacyTokenCache(backend, maxEntries) {
|
|
|
50
73
|
await l1.clear();
|
|
51
74
|
},
|
|
52
75
|
destroy: async () => {
|
|
76
|
+
if (l1 instanceof IdlePrivacyTokenMemoryStore)
|
|
77
|
+
l1.detach();
|
|
53
78
|
await l1.clear();
|
|
54
79
|
await backend.destroy?.();
|
|
55
80
|
}
|
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
import { SenderKeyMemoryStore } from '../memory/sender-key.store.js';
|
|
2
|
+
import { IdleExpiryIndex } from '../../util/collections.js';
|
|
3
|
+
/** L1 with idle expiry; hits keep both maps in access order, so the caps evict LRU entries. */
|
|
4
|
+
class IdleSenderKeyMemoryStore extends SenderKeyMemoryStore {
|
|
5
|
+
constructor(maxEntries, expiry) {
|
|
6
|
+
super({ maxSenderKeys: maxEntries, maxSenderDistributions: maxEntries });
|
|
7
|
+
this.idleSenderKeys = new IdleExpiryIndex(this.senderKeys, expiry);
|
|
8
|
+
this.idleDistributions = new IdleExpiryIndex(this.senderDistributions, expiry);
|
|
9
|
+
}
|
|
10
|
+
detach() {
|
|
11
|
+
this.idleSenderKeys.detach();
|
|
12
|
+
this.idleDistributions.detach();
|
|
13
|
+
}
|
|
14
|
+
onEntryAccess(entries, key) {
|
|
15
|
+
this.indexFor(entries).touch(key);
|
|
16
|
+
}
|
|
17
|
+
onEntryRemove(entries, key) {
|
|
18
|
+
this.indexFor(entries).delete(key);
|
|
19
|
+
}
|
|
20
|
+
onEntriesClear() {
|
|
21
|
+
this.idleSenderKeys.clear();
|
|
22
|
+
this.idleDistributions.clear();
|
|
23
|
+
}
|
|
24
|
+
indexFor(entries) {
|
|
25
|
+
return entries === this.senderKeys ? this.idleSenderKeys : this.idleDistributions;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
2
28
|
/**
|
|
3
29
|
* Read-through / write-through in-process cache for a persistent sender-key
|
|
4
|
-
* backend. Reuses {@link SenderKeyMemoryStore} as the bounded
|
|
30
|
+
* backend. Reuses {@link SenderKeyMemoryStore} as the bounded L1 so the
|
|
5
31
|
* per-(group, sender) lookups repeated across a group fan-out skip the
|
|
6
32
|
* backend round-trip.
|
|
7
33
|
*
|
|
@@ -12,13 +38,16 @@ import { SenderKeyMemoryStore } from '../memory/sender-key.store.js';
|
|
|
12
38
|
* (`deleteDeviceSenderKey`/`markForgetSenderKey`) run on the backend for the
|
|
13
39
|
* authoritative count and then invalidate the matching L1 entries via the
|
|
14
40
|
* memory store's own sweep. See {@link withSessionCache} for the shared
|
|
15
|
-
* coherence model
|
|
41
|
+
* coherence model, the single-writer-per-session assumption and the eviction
|
|
42
|
+
* with and without `expiry`.
|
|
16
43
|
*/
|
|
17
|
-
export function withSenderKeyCache(backend, maxEntries) {
|
|
18
|
-
const l1 =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
44
|
+
export function withSenderKeyCache(backend, maxEntries, expiry) {
|
|
45
|
+
const l1 = expiry
|
|
46
|
+
? new IdleSenderKeyMemoryStore(maxEntries, expiry)
|
|
47
|
+
: new SenderKeyMemoryStore({
|
|
48
|
+
maxSenderKeys: maxEntries,
|
|
49
|
+
maxSenderDistributions: maxEntries
|
|
50
|
+
});
|
|
22
51
|
let generation = 0;
|
|
23
52
|
return {
|
|
24
53
|
upsertSenderKey: async (record) => {
|
|
@@ -94,6 +123,8 @@ export function withSenderKeyCache(backend, maxEntries) {
|
|
|
94
123
|
await l1.clear();
|
|
95
124
|
},
|
|
96
125
|
destroy: async () => {
|
|
126
|
+
if (l1 instanceof IdleSenderKeyMemoryStore)
|
|
127
|
+
l1.detach();
|
|
97
128
|
await l1.clear();
|
|
98
129
|
await backend.destroy?.();
|
|
99
130
|
}
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
import { WaSessionMemoryStore } from '../memory/session.store.js';
|
|
2
|
+
import { IdleExpiryIndex } from '../../util/collections.js';
|
|
3
|
+
/** L1 with idle expiry; hits keep it in access order, so the cap evicts the LRU entry. */
|
|
4
|
+
class IdleSessionMemoryStore extends WaSessionMemoryStore {
|
|
5
|
+
constructor(maxSessions, expiry) {
|
|
6
|
+
super({ maxSessions });
|
|
7
|
+
this.idle = new IdleExpiryIndex(this.signalSessions, expiry);
|
|
8
|
+
}
|
|
9
|
+
detach() {
|
|
10
|
+
this.idle.detach();
|
|
11
|
+
}
|
|
12
|
+
onEntryAccess(key) {
|
|
13
|
+
this.idle.touch(key);
|
|
14
|
+
}
|
|
15
|
+
onEntryRemove(key) {
|
|
16
|
+
this.idle.delete(key);
|
|
17
|
+
}
|
|
18
|
+
onEntriesClear() {
|
|
19
|
+
this.idle.clear();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
2
22
|
/**
|
|
3
23
|
* Read-through / write-through in-process cache for a persistent session
|
|
4
|
-
* backend. Reuses {@link WaSessionMemoryStore} as the bounded
|
|
24
|
+
* backend. Reuses {@link WaSessionMemoryStore} as the bounded L1 so that
|
|
5
25
|
* repeated reads of the same peer on the send/recv path skip the backend
|
|
6
|
-
* round-trip.
|
|
26
|
+
* round-trip. Without `expiry` the cap evicts the least recently written
|
|
27
|
+
* entry; with it, the least recently used, and entries idle past the TTL
|
|
28
|
+
* leave the L1 (never the backend) and are re-read on next use.
|
|
7
29
|
*
|
|
8
30
|
* Coherence model (single process):
|
|
9
31
|
* - every mutation (set/delete/clear) writes the backend then the L1 in
|
|
@@ -19,8 +41,10 @@ import { WaSessionMemoryStore } from '../memory/session.store.js';
|
|
|
19
41
|
* session with the cache enabled - there is no cross-process invalidation
|
|
20
42
|
* channel, so another process's writes would leave this L1 stale.
|
|
21
43
|
*/
|
|
22
|
-
export function withSessionCache(backend, maxEntries) {
|
|
23
|
-
const l1 =
|
|
44
|
+
export function withSessionCache(backend, maxEntries, expiry) {
|
|
45
|
+
const l1 = expiry
|
|
46
|
+
? new IdleSessionMemoryStore(maxEntries, expiry)
|
|
47
|
+
: new WaSessionMemoryStore({ maxSessions: maxEntries });
|
|
24
48
|
let generation = 0;
|
|
25
49
|
return {
|
|
26
50
|
hasSession: async (address) => {
|
|
@@ -85,6 +109,8 @@ export function withSessionCache(backend, maxEntries) {
|
|
|
85
109
|
await l1.clear();
|
|
86
110
|
},
|
|
87
111
|
destroy: async () => {
|
|
112
|
+
if (l1 instanceof IdleSessionMemoryStore)
|
|
113
|
+
l1.detach();
|
|
88
114
|
await l1.clear();
|
|
89
115
|
await backend.destroy?.();
|
|
90
116
|
}
|
|
@@ -35,6 +35,7 @@ import { WaSignalMemoryStore } from './memory/signal.store.js';
|
|
|
35
35
|
import { WaThreadMemoryStore } from './memory/thread.store.js';
|
|
36
36
|
import { NOOP_CHAT_METADATA_STORE, NOOP_CONTACT_STORE, NOOP_DEVICE_LIST_STORE, NOOP_GROUP_METADATA_STORE, NOOP_MESSAGE_SECRET_STORE, NOOP_MESSAGE_STORE, NOOP_RETRY_STORE, NOOP_THREAD_STORE } from './noop.store.js';
|
|
37
37
|
import { resolvePositive } from '../util/coercion.js';
|
|
38
|
+
import { IdleSweepClock, resolveIdleSweepPeriodMs } from '../util/collections.js';
|
|
38
39
|
import { toError } from '../util/primitives.js';
|
|
39
40
|
const DEFAULT_CACHE_TTLS_MS = Object.freeze({
|
|
40
41
|
retryMs: 60 * 1000,
|
|
@@ -56,6 +57,8 @@ const REQUIRED_PROVIDER_DOMAINS = [
|
|
|
56
57
|
'contacts',
|
|
57
58
|
'privacyToken'
|
|
58
59
|
];
|
|
60
|
+
const CACHE_LAYER_DOMAINS = ['session', 'identity', 'senderKey', 'privacyToken'];
|
|
61
|
+
const MIN_CACHE_LAYER_TTL_MS = 1000;
|
|
59
62
|
function hasDestroy(value) {
|
|
60
63
|
return (!!value &&
|
|
61
64
|
typeof value === 'object' &&
|
|
@@ -70,6 +73,37 @@ async function destroyIfSupported(value) {
|
|
|
70
73
|
function usesBackend(provider) {
|
|
71
74
|
return !!provider && provider !== 'memory' && provider !== 'none';
|
|
72
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Validates `cacheLayer.ttlMs` and hands each domain that gets an L1 its idle
|
|
78
|
+
* expiry, all on one clock. A TTL on a domain without an L1 is warned and dropped.
|
|
79
|
+
*/
|
|
80
|
+
function resolveCacheLayerExpiry(cacheLayer, providers, logger) {
|
|
81
|
+
const ttls = [];
|
|
82
|
+
for (const domain of CACHE_LAYER_DOMAINS) {
|
|
83
|
+
const ttlMs = cacheLayer.ttlMs?.[`${domain}Ms`];
|
|
84
|
+
if (ttlMs === undefined)
|
|
85
|
+
continue;
|
|
86
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < MIN_CACHE_LAYER_TTL_MS) {
|
|
87
|
+
throw new Error(`cacheLayer.ttlMs.${domain}Ms must be a safe integer >= ${MIN_CACHE_LAYER_TTL_MS}`);
|
|
88
|
+
}
|
|
89
|
+
if (cacheLayer[domain] && usesBackend(providers[domain])) {
|
|
90
|
+
ttls.push({ domain, ttlMs });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
logger?.warn('cacheLayer ttl ignored: domain has no L1', {
|
|
94
|
+
domain,
|
|
95
|
+
cached: cacheLayer[domain] === true,
|
|
96
|
+
provider: providers[domain] ?? 'memory'
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const expiry = {};
|
|
100
|
+
if (ttls.length === 0)
|
|
101
|
+
return expiry;
|
|
102
|
+
const clock = new IdleSweepClock(resolveIdleSweepPeriodMs(ttls.map((entry) => entry.ttlMs)));
|
|
103
|
+
for (const { domain, ttlMs } of ttls)
|
|
104
|
+
expiry[domain] = { clock, ttlMs };
|
|
105
|
+
return expiry;
|
|
106
|
+
}
|
|
73
107
|
function resolveStore(sessionId, backends, provider, domain, kind, fallback) {
|
|
74
108
|
if (!provider || provider === 'memory' || provider === 'none') {
|
|
75
109
|
return fallback();
|
|
@@ -112,6 +146,7 @@ export function createStore(options) {
|
|
|
112
146
|
deviceList: resolvePositive(options.memory?.cacheTtlMs?.deviceListMs, DEFAULT_CACHE_TTLS_MS.deviceListMs, 'memory.cacheTtlMs.deviceListMs'),
|
|
113
147
|
messageSecret: resolvePositive(options.memory?.cacheTtlMs?.messageSecretMs, DEFAULT_CACHE_TTLS_MS.messageSecretMs, 'memory.cacheTtlMs.messageSecretMs')
|
|
114
148
|
});
|
|
149
|
+
const cacheLayerExpiry = resolveCacheLayerExpiry(cacheLayer, providers, storeLogger);
|
|
115
150
|
const sessions = new Map();
|
|
116
151
|
const pendingSessionDestroys = new Set();
|
|
117
152
|
let storeDestroyed = false;
|
|
@@ -208,20 +243,20 @@ export function createStore(options) {
|
|
|
208
243
|
const signalStore = withSignalLock(rawSignal);
|
|
209
244
|
const preKeyStore = withPreKeyLock(rawPreKey);
|
|
210
245
|
const sessionStore = withSessionLock(cacheLayer.session && usesBackend(providers.session)
|
|
211
|
-
? withSessionCache(rawSession, cacheLayer.limits?.session)
|
|
246
|
+
? withSessionCache(rawSession, cacheLayer.limits?.session, cacheLayerExpiry.session)
|
|
212
247
|
: rawSession);
|
|
213
248
|
const identityStore = withIdentityLock(cacheLayer.identity && usesBackend(providers.identity)
|
|
214
|
-
? withIdentityCache(rawIdentity, cacheLayer.limits?.identity)
|
|
249
|
+
? withIdentityCache(rawIdentity, cacheLayer.limits?.identity, cacheLayerExpiry.identity)
|
|
215
250
|
: rawIdentity);
|
|
216
251
|
const senderKeyStore = withSenderKeyLock(cacheLayer.senderKey && usesBackend(providers.senderKey)
|
|
217
|
-
? withSenderKeyCache(rawSenderKey, cacheLayer.limits?.senderKey)
|
|
252
|
+
? withSenderKeyCache(rawSenderKey, cacheLayer.limits?.senderKey, cacheLayerExpiry.senderKey)
|
|
218
253
|
: rawSenderKey);
|
|
219
254
|
const appStateStore = withAppStateLock(rawAppState);
|
|
220
255
|
const messageStore = withMessageLock(rawMessages);
|
|
221
256
|
const threadStore = withThreadLock(rawThreads);
|
|
222
257
|
const contactStore = withContactLock(rawContacts);
|
|
223
258
|
const privacyTokenStore = withPrivacyTokenLock(cacheLayer.privacyToken && usesBackend(providers.privacyToken)
|
|
224
|
-
? withPrivacyTokenCache(rawPrivacyToken, cacheLayer.limits?.privacyToken)
|
|
259
|
+
? withPrivacyTokenCache(rawPrivacyToken, cacheLayer.limits?.privacyToken, cacheLayerExpiry.privacyToken)
|
|
225
260
|
: rawPrivacyToken);
|
|
226
261
|
let sessionDestroyed = false;
|
|
227
262
|
let destroyPromise = null;
|