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.
- package/README.md +14 -13
- package/dist/auth/credentials-flow.js +2 -3
- 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/auth/credentials-flow.js +3 -4
- 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/message/encode/content.js +4 -1
- 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/transport/node/WaMobileTcpSocket.js +69 -9
- package/dist/esm/transport/noise/WaMobileClientPayload.js +27 -7
- package/dist/esm/transport/proxy.js +83 -0
- package/dist/esm/util/collections.js +94 -0
- package/dist/esm/util/index.js +2 -2
- package/dist/message/encode/content.js +4 -1
- 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/transport/node/WaMobileTcpSocket.d.ts +3 -1
- package/dist/transport/node/WaMobileTcpSocket.js +68 -8
- package/dist/transport/noise/WaMobileClientPayload.d.ts +22 -0
- package/dist/transport/noise/WaMobileClientPayload.js +27 -7
- package/dist/transport/proxy.d.ts +24 -0
- package/dist/transport/proxy.js +85 -0
- 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
|
@@ -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;
|
|
@@ -4,28 +4,52 @@ import { setBoundedMapEntry } from '../../util/collections.js';
|
|
|
4
4
|
const DEFAULT_MAX_REMOTE_IDENTITIES = 8192;
|
|
5
5
|
export class WaIdentityMemoryStore {
|
|
6
6
|
constructor(options = {}) {
|
|
7
|
+
this.onEvict = (key) => this.onEntryRemove(key);
|
|
7
8
|
this.remoteIdentities = new Map();
|
|
8
9
|
this.maxRemoteIdentities = resolvePositive(options.maxRemoteIdentities, DEFAULT_MAX_REMOTE_IDENTITIES, 'WaIdentityMemoryStoreOptions.maxRemoteIdentities');
|
|
9
10
|
}
|
|
10
11
|
async getRemoteIdentity(address) {
|
|
11
|
-
|
|
12
|
+
const key = signalAddressKey(address);
|
|
13
|
+
const identityKey = this.remoteIdentities.get(key);
|
|
14
|
+
if (identityKey === undefined)
|
|
15
|
+
return null;
|
|
16
|
+
this.onEntryAccess(key);
|
|
17
|
+
return identityKey;
|
|
12
18
|
}
|
|
13
19
|
async getRemoteIdentities(addresses) {
|
|
14
20
|
const result = new Array(addresses.length);
|
|
15
21
|
for (let i = 0; i < addresses.length; i += 1) {
|
|
16
|
-
|
|
22
|
+
const key = signalAddressKey(addresses[i]);
|
|
23
|
+
const identityKey = this.remoteIdentities.get(key);
|
|
24
|
+
if (identityKey === undefined) {
|
|
25
|
+
result[i] = null;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
this.onEntryAccess(key);
|
|
29
|
+
result[i] = identityKey;
|
|
30
|
+
}
|
|
17
31
|
}
|
|
18
32
|
return result;
|
|
19
33
|
}
|
|
20
34
|
async setRemoteIdentity(address, identityKey) {
|
|
21
|
-
|
|
35
|
+
const key = signalAddressKey(address);
|
|
36
|
+
setBoundedMapEntry(this.remoteIdentities, key, identityKey, this.maxRemoteIdentities, this.onEvict);
|
|
37
|
+
this.onEntryAccess(key);
|
|
22
38
|
}
|
|
23
39
|
async setRemoteIdentities(entries) {
|
|
24
40
|
for (const entry of entries) {
|
|
25
|
-
|
|
41
|
+
const key = signalAddressKey(entry.address);
|
|
42
|
+
setBoundedMapEntry(this.remoteIdentities, key, entry.identityKey, this.maxRemoteIdentities, this.onEvict);
|
|
43
|
+
this.onEntryAccess(key);
|
|
26
44
|
}
|
|
27
45
|
}
|
|
28
46
|
async clear() {
|
|
29
47
|
this.remoteIdentities.clear();
|
|
48
|
+
this.onEntriesClear();
|
|
30
49
|
}
|
|
50
|
+
/** Runs with the key of every read hit and write. No-op unless a subclass tracks access. */
|
|
51
|
+
onEntryAccess(key) { }
|
|
52
|
+
/** Runs with the key of every cap-evicted entry. */
|
|
53
|
+
onEntryRemove(key) { }
|
|
54
|
+
onEntriesClear() { }
|
|
31
55
|
}
|
|
@@ -1,35 +1,52 @@
|
|
|
1
|
+
import { resolvePositive } from '../../util/coercion.js';
|
|
1
2
|
import { setBoundedMapEntry } from '../../util/collections.js';
|
|
2
3
|
const DEFAULT_MAX_ENTRIES = 10000;
|
|
3
4
|
export class WaPrivacyTokenMemoryStore {
|
|
4
|
-
constructor(maxEntries
|
|
5
|
+
constructor(maxEntries) {
|
|
6
|
+
this.onEvict = (jid) => this.onEntryRemove(jid);
|
|
5
7
|
this.records = new Map();
|
|
6
|
-
this.maxEntries = maxEntries;
|
|
8
|
+
this.maxEntries = resolvePositive(maxEntries, DEFAULT_MAX_ENTRIES, 'WaPrivacyTokenMemoryStore.maxEntries');
|
|
7
9
|
}
|
|
8
10
|
async upsert(record) {
|
|
9
11
|
const existing = this.records.get(record.jid);
|
|
10
12
|
const merged = existing ? this.mergeRecord(existing, record) : record;
|
|
11
|
-
setBoundedMapEntry(this.records, record.jid, merged, this.maxEntries);
|
|
13
|
+
setBoundedMapEntry(this.records, record.jid, merged, this.maxEntries, this.onEvict);
|
|
14
|
+
this.onEntryAccess(record.jid);
|
|
12
15
|
}
|
|
13
16
|
async upsertBatch(records) {
|
|
14
17
|
for (let i = 0; i < records.length; i += 1) {
|
|
15
18
|
const record = records[i];
|
|
16
19
|
const existing = this.records.get(record.jid);
|
|
17
20
|
const merged = existing ? this.mergeRecord(existing, record) : record;
|
|
18
|
-
setBoundedMapEntry(this.records, record.jid, merged, this.maxEntries);
|
|
21
|
+
setBoundedMapEntry(this.records, record.jid, merged, this.maxEntries, this.onEvict);
|
|
22
|
+
this.onEntryAccess(record.jid);
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
async getByJid(jid) {
|
|
22
|
-
|
|
26
|
+
const record = this.records.get(jid);
|
|
27
|
+
if (record === undefined)
|
|
28
|
+
return null;
|
|
29
|
+
this.onEntryAccess(jid);
|
|
30
|
+
return record;
|
|
23
31
|
}
|
|
24
32
|
async deleteByJid(jid) {
|
|
25
|
-
|
|
33
|
+
if (!this.records.delete(jid))
|
|
34
|
+
return 0;
|
|
35
|
+
this.onEntryRemove(jid);
|
|
36
|
+
return 1;
|
|
26
37
|
}
|
|
27
38
|
async clear() {
|
|
28
39
|
this.records.clear();
|
|
40
|
+
this.onEntriesClear();
|
|
29
41
|
}
|
|
30
42
|
async destroy() {
|
|
31
|
-
this.
|
|
43
|
+
await this.clear();
|
|
32
44
|
}
|
|
45
|
+
/** Runs with the jid of every read hit and write. No-op unless a subclass tracks access. */
|
|
46
|
+
onEntryAccess(jid) { }
|
|
47
|
+
/** Runs with the jid of every deleted or cap-evicted record. */
|
|
48
|
+
onEntryRemove(jid) { }
|
|
49
|
+
onEntriesClear() { }
|
|
33
50
|
mergeRecord(existing, incoming) {
|
|
34
51
|
return {
|
|
35
52
|
jid: incoming.jid,
|
|
@@ -7,20 +7,28 @@ const DEFAULT_SENDER_KEY_STORE_LIMITS = Object.freeze({
|
|
|
7
7
|
});
|
|
8
8
|
export class SenderKeyMemoryStore {
|
|
9
9
|
constructor(options = {}) {
|
|
10
|
+
this.onSenderKeyEvict = (key) => this.onEntryRemove(this.senderKeys, key);
|
|
11
|
+
this.onDistributionEvict = (key) => this.onEntryRemove(this.senderDistributions, key);
|
|
10
12
|
this.senderKeys = new Map();
|
|
11
13
|
this.senderDistributions = new Map();
|
|
12
14
|
this.maxSenderKeys = resolvePositive(options.maxSenderKeys, DEFAULT_SENDER_KEY_STORE_LIMITS.senderKeys, 'WaSenderKeyMemoryStoreOptions.maxSenderKeys');
|
|
13
15
|
this.maxSenderDistributions = resolvePositive(options.maxSenderDistributions, DEFAULT_SENDER_KEY_STORE_LIMITS.senderDistributions, 'WaSenderKeyMemoryStoreOptions.maxSenderDistributions');
|
|
14
16
|
}
|
|
15
17
|
async upsertSenderKey(record) {
|
|
16
|
-
|
|
18
|
+
const key = this.makeKey(record.groupId, record.sender);
|
|
19
|
+
setBoundedMapEntry(this.senderKeys, key, record, this.maxSenderKeys, this.onSenderKeyEvict);
|
|
20
|
+
this.onEntryAccess(this.senderKeys, key);
|
|
17
21
|
}
|
|
18
22
|
async upsertSenderKeyDistribution(record) {
|
|
19
|
-
|
|
23
|
+
const key = this.makeKey(record.groupId, record.sender);
|
|
24
|
+
setBoundedMapEntry(this.senderDistributions, key, record, this.maxSenderDistributions, this.onDistributionEvict);
|
|
25
|
+
this.onEntryAccess(this.senderDistributions, key);
|
|
20
26
|
}
|
|
21
27
|
async upsertSenderKeyDistributions(records) {
|
|
22
28
|
for (const record of records) {
|
|
23
|
-
|
|
29
|
+
const key = this.makeKey(record.groupId, record.sender);
|
|
30
|
+
setBoundedMapEntry(this.senderDistributions, key, record, this.maxSenderDistributions, this.onDistributionEvict);
|
|
31
|
+
this.onEntryAccess(this.senderDistributions, key);
|
|
24
32
|
}
|
|
25
33
|
}
|
|
26
34
|
async getGroupSenderKeyList(groupId) {
|
|
@@ -42,14 +50,25 @@ export class SenderKeyMemoryStore {
|
|
|
42
50
|
};
|
|
43
51
|
}
|
|
44
52
|
async getDeviceSenderKey(groupId, sender) {
|
|
45
|
-
const
|
|
46
|
-
|
|
53
|
+
const key = this.makeKey(groupId, sender);
|
|
54
|
+
const record = this.senderKeys.get(key);
|
|
55
|
+
if (record === undefined)
|
|
56
|
+
return null;
|
|
57
|
+
this.onEntryAccess(this.senderKeys, key);
|
|
58
|
+
return record;
|
|
47
59
|
}
|
|
48
60
|
async getDeviceSenderKeyDistributions(groupId, senders) {
|
|
49
61
|
const records = new Array(senders.length);
|
|
50
62
|
for (let index = 0; index < senders.length; index += 1) {
|
|
51
|
-
|
|
52
|
-
|
|
63
|
+
const key = this.makeKey(groupId, senders[index]);
|
|
64
|
+
const record = this.senderDistributions.get(key);
|
|
65
|
+
if (record === undefined) {
|
|
66
|
+
records[index] = null;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
this.onEntryAccess(this.senderDistributions, key);
|
|
70
|
+
records[index] = record;
|
|
71
|
+
}
|
|
53
72
|
}
|
|
54
73
|
return records;
|
|
55
74
|
}
|
|
@@ -71,7 +90,16 @@ export class SenderKeyMemoryStore {
|
|
|
71
90
|
async clear() {
|
|
72
91
|
this.senderKeys.clear();
|
|
73
92
|
this.senderDistributions.clear();
|
|
93
|
+
this.onEntriesClear();
|
|
74
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Runs with the map and key of every point-read hit and write (the group
|
|
97
|
+
* scan does not count). No-op unless a subclass tracks access.
|
|
98
|
+
*/
|
|
99
|
+
onEntryAccess(entries, key) { }
|
|
100
|
+
/** Runs with the map and key of every deleted or cap-evicted entry. */
|
|
101
|
+
onEntryRemove(entries, key) { }
|
|
102
|
+
onEntriesClear() { }
|
|
75
103
|
deleteMatching(map, target, groupId) {
|
|
76
104
|
let deleted = 0;
|
|
77
105
|
const targetAddressKey = signalAddressKey(target);
|
|
@@ -80,6 +108,7 @@ export class SenderKeyMemoryStore {
|
|
|
80
108
|
const sameAddress = signalAddressKey(record.sender) === targetAddressKey;
|
|
81
109
|
if (sameGroup && sameAddress) {
|
|
82
110
|
map.delete(key);
|
|
111
|
+
this.onEntryRemove(map, key);
|
|
83
112
|
deleted += 1;
|
|
84
113
|
}
|
|
85
114
|
}
|
|
@@ -4,42 +4,76 @@ import { setBoundedMapEntry } from '../../util/collections.js';
|
|
|
4
4
|
const DEFAULT_MAX_SESSIONS = 8192;
|
|
5
5
|
export class WaSessionMemoryStore {
|
|
6
6
|
constructor(options = {}) {
|
|
7
|
+
this.onEvict = (key) => this.onEntryRemove(key);
|
|
7
8
|
this.signalSessions = new Map();
|
|
8
9
|
this.maxSessions = resolvePositive(options.maxSessions, DEFAULT_MAX_SESSIONS, 'WaSessionMemoryStoreOptions.maxSessions');
|
|
9
10
|
}
|
|
10
11
|
async hasSession(address) {
|
|
11
|
-
|
|
12
|
+
const key = signalAddressKey(address);
|
|
13
|
+
if (!this.signalSessions.has(key))
|
|
14
|
+
return false;
|
|
15
|
+
this.onEntryAccess(key);
|
|
16
|
+
return true;
|
|
12
17
|
}
|
|
13
18
|
async hasSessions(addresses) {
|
|
14
19
|
const result = new Array(addresses.length);
|
|
15
20
|
for (let i = 0; i < addresses.length; i += 1) {
|
|
16
|
-
|
|
21
|
+
const key = signalAddressKey(addresses[i]);
|
|
22
|
+
const found = this.signalSessions.has(key);
|
|
23
|
+
if (found)
|
|
24
|
+
this.onEntryAccess(key);
|
|
25
|
+
result[i] = found;
|
|
17
26
|
}
|
|
18
27
|
return result;
|
|
19
28
|
}
|
|
20
29
|
async getSession(address) {
|
|
21
|
-
|
|
30
|
+
const key = signalAddressKey(address);
|
|
31
|
+
const session = this.signalSessions.get(key);
|
|
32
|
+
if (session === undefined)
|
|
33
|
+
return null;
|
|
34
|
+
this.onEntryAccess(key);
|
|
35
|
+
return session;
|
|
22
36
|
}
|
|
23
37
|
async getSessionsBatch(addresses) {
|
|
24
38
|
const result = new Array(addresses.length);
|
|
25
39
|
for (let i = 0; i < addresses.length; i += 1) {
|
|
26
|
-
|
|
40
|
+
const key = signalAddressKey(addresses[i]);
|
|
41
|
+
const session = this.signalSessions.get(key);
|
|
42
|
+
if (session === undefined) {
|
|
43
|
+
result[i] = null;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
this.onEntryAccess(key);
|
|
47
|
+
result[i] = session;
|
|
48
|
+
}
|
|
27
49
|
}
|
|
28
50
|
return result;
|
|
29
51
|
}
|
|
30
52
|
async setSession(address, session) {
|
|
31
|
-
|
|
53
|
+
const key = signalAddressKey(address);
|
|
54
|
+
setBoundedMapEntry(this.signalSessions, key, session, this.maxSessions, this.onEvict);
|
|
55
|
+
this.onEntryAccess(key);
|
|
32
56
|
}
|
|
33
57
|
async setSessionsBatch(entries) {
|
|
34
58
|
for (let index = 0; index < entries.length; index += 1) {
|
|
35
59
|
const entry = entries[index];
|
|
36
|
-
|
|
60
|
+
const key = signalAddressKey(entry.address);
|
|
61
|
+
setBoundedMapEntry(this.signalSessions, key, entry.session, this.maxSessions, this.onEvict);
|
|
62
|
+
this.onEntryAccess(key);
|
|
37
63
|
}
|
|
38
64
|
}
|
|
39
65
|
async deleteSession(address) {
|
|
40
|
-
|
|
66
|
+
const key = signalAddressKey(address);
|
|
67
|
+
if (this.signalSessions.delete(key))
|
|
68
|
+
this.onEntryRemove(key);
|
|
41
69
|
}
|
|
42
70
|
async clear() {
|
|
43
71
|
this.signalSessions.clear();
|
|
72
|
+
this.onEntriesClear();
|
|
44
73
|
}
|
|
74
|
+
/** Runs with the key of every read hit and write. No-op unless a subclass tracks access. */
|
|
75
|
+
onEntryAccess(key) { }
|
|
76
|
+
/** Runs with the key of every deleted or cap-evicted entry. */
|
|
77
|
+
onEntryRemove(key) { }
|
|
78
|
+
onEntriesClear() { }
|
|
45
79
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { connect as netConnect } from 'node:net';
|
|
2
2
|
import { WA_READY_STATES } from '../../protocol/constants.js';
|
|
3
|
-
import {
|
|
3
|
+
import { toTcpProxyEndpoint } from '../proxy.js';
|
|
4
|
+
import { concatBytes, TEXT_DECODER, TEXT_ENCODER } from '../../util/bytes.js';
|
|
4
5
|
/**
|
|
5
6
|
* `RawWebSocket`-shaped adapter over a raw Node TCP socket. Used by the
|
|
6
7
|
* mobile transport to speak the WhatsApp Mobile binary protocol over
|
|
7
8
|
* `tcp://host:port` URLs.
|
|
8
9
|
*/
|
|
9
10
|
export class WaMobileTcpSocket {
|
|
10
|
-
constructor(url, _protocols,
|
|
11
|
+
constructor(url, _protocols, options) {
|
|
11
12
|
this.binaryType = 'arraybuffer';
|
|
12
13
|
this.readyState = WA_READY_STATES.CONNECTING;
|
|
13
14
|
this.onopen = null;
|
|
@@ -19,19 +20,55 @@ export class WaMobileTcpSocket {
|
|
|
19
20
|
this.closedClean = true;
|
|
20
21
|
this.forceCloseTimer = null;
|
|
21
22
|
const { host, port } = parseTcpUrl(url);
|
|
22
|
-
|
|
23
|
+
const proxy = toTcpProxyEndpoint(options?.agent);
|
|
24
|
+
this.socket = proxy
|
|
25
|
+
? netConnect({ host: proxy.hostname, port: proxy.port })
|
|
26
|
+
: netConnect({ host, port });
|
|
27
|
+
let tunnelReady = !proxy;
|
|
28
|
+
let proxyResponse = new Uint8Array(0);
|
|
23
29
|
this.socket.on('connect', () => {
|
|
24
30
|
if (this.readyState !== WA_READY_STATES.CONNECTING)
|
|
25
31
|
return;
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
if (proxy) {
|
|
33
|
+
const authority = `${host}:${port}`;
|
|
34
|
+
const lines = [
|
|
35
|
+
`CONNECT ${authority} HTTP/1.1`,
|
|
36
|
+
`Host: ${authority}`,
|
|
37
|
+
'Proxy-Connection: Keep-Alive'
|
|
38
|
+
];
|
|
39
|
+
if (proxy.authorization)
|
|
40
|
+
lines.push(`Proxy-Authorization: ${proxy.authorization}`);
|
|
41
|
+
this.socket.write(TEXT_ENCODER.encode(`${lines.join('\r\n')}\r\n\r\n`));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
this.markOpen();
|
|
28
45
|
});
|
|
29
46
|
this.socket.on('data', (chunk) => {
|
|
30
|
-
if (!
|
|
47
|
+
if (!tunnelReady && proxy) {
|
|
48
|
+
proxyResponse = concatBytes([proxyResponse, chunk]);
|
|
49
|
+
const headerEnd = findHttpHeaderEnd(proxyResponse);
|
|
50
|
+
const scanned = headerEnd === -1 ? proxyResponse.byteLength : headerEnd;
|
|
51
|
+
if (scanned > MAX_PROXY_HEADER_BYTES) {
|
|
52
|
+
this.socket.destroy(new Error('WaMobileTcpSocket: proxy response headers too large'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (headerEnd === -1) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const statusLine = TEXT_DECODER.decode(proxyResponse.subarray(0, headerEnd)).split('\r\n')[0];
|
|
59
|
+
if (!/^HTTP\/1\.[01] 2\d\d(?:\s|$)/.test(statusLine)) {
|
|
60
|
+
this.socket.destroy(new Error(`WaMobileTcpSocket: proxy CONNECT failed (${statusLine})`));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const remaining = proxyResponse.subarray(headerEnd + 4);
|
|
64
|
+
proxyResponse = new Uint8Array(0);
|
|
65
|
+
tunnelReady = true;
|
|
66
|
+
this.markOpen();
|
|
67
|
+
if (remaining.byteLength > 0)
|
|
68
|
+
this.emitMessage(remaining);
|
|
31
69
|
return;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
this.onmessage({ data: copy });
|
|
70
|
+
}
|
|
71
|
+
this.emitMessage(chunk);
|
|
35
72
|
});
|
|
36
73
|
this.socket.on('error', (err) => {
|
|
37
74
|
this.closedClean = false;
|
|
@@ -53,6 +90,19 @@ export class WaMobileTcpSocket {
|
|
|
53
90
|
});
|
|
54
91
|
});
|
|
55
92
|
}
|
|
93
|
+
markOpen() {
|
|
94
|
+
if (this.readyState !== WA_READY_STATES.CONNECTING)
|
|
95
|
+
return;
|
|
96
|
+
this.readyState = WA_READY_STATES.OPEN;
|
|
97
|
+
this.onopen?.({});
|
|
98
|
+
}
|
|
99
|
+
emitMessage(chunk) {
|
|
100
|
+
if (!this.onmessage || this.readyState !== WA_READY_STATES.OPEN)
|
|
101
|
+
return;
|
|
102
|
+
const copy = new Uint8Array(chunk.byteLength);
|
|
103
|
+
copy.set(chunk);
|
|
104
|
+
this.onmessage({ data: copy });
|
|
105
|
+
}
|
|
56
106
|
send(data) {
|
|
57
107
|
if (this.readyState !== WA_READY_STATES.OPEN) {
|
|
58
108
|
throw new Error('WaMobileTcpSocket: send() called on non-OPEN socket');
|
|
@@ -85,6 +135,16 @@ export class WaMobileTcpSocket {
|
|
|
85
135
|
this.forceCloseTimer.unref();
|
|
86
136
|
}
|
|
87
137
|
}
|
|
138
|
+
/** Cap on the CONNECT response headers buffered before the tunnel is rejected. */
|
|
139
|
+
const MAX_PROXY_HEADER_BYTES = 65536;
|
|
140
|
+
function findHttpHeaderEnd(bytes) {
|
|
141
|
+
for (let i = 3; i < bytes.byteLength; i += 1) {
|
|
142
|
+
if (bytes[i - 3] === 13 && bytes[i - 2] === 10 && bytes[i - 1] === 13 && bytes[i] === 10) {
|
|
143
|
+
return i - 3;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return -1;
|
|
147
|
+
}
|
|
88
148
|
function parseTcpUrl(url) {
|
|
89
149
|
let work = url;
|
|
90
150
|
if (work.startsWith('tcp://')) {
|
|
@@ -13,6 +13,20 @@ function parseAppVersion(version) {
|
|
|
13
13
|
quaternary: at(3)
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
+
function distributionChannelId(channel) {
|
|
17
|
+
const { DistributionChannel } = proto.ClientPayload.UserAgent;
|
|
18
|
+
switch (channel) {
|
|
19
|
+
case 'website':
|
|
20
|
+
return DistributionChannel.WEBSITE;
|
|
21
|
+
case 'testflight':
|
|
22
|
+
return DistributionChannel.TESTFLIGHT;
|
|
23
|
+
case 'internal':
|
|
24
|
+
return DistributionChannel.INTERNAL;
|
|
25
|
+
case 'appstore':
|
|
26
|
+
case undefined:
|
|
27
|
+
return DistributionChannel.APPSTORE;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
16
30
|
/**
|
|
17
31
|
* Builds the encoded {@link Proto.ClientPayload} bytes the WhatsApp Mobile
|
|
18
32
|
* transport sends after the noise login handshake. Throws when
|
|
@@ -24,14 +38,18 @@ export function buildMobileLoginPayload(config) {
|
|
|
24
38
|
}
|
|
25
39
|
const info = config.deviceInfo;
|
|
26
40
|
const version = parseAppVersion(info.appVersion);
|
|
27
|
-
const
|
|
28
|
-
|
|
41
|
+
const isIos = info.os === 'ios';
|
|
42
|
+
const platform = isIos
|
|
43
|
+
? info.business
|
|
44
|
+
? proto.ClientPayload.UserAgent.Platform.SMB_IOS
|
|
45
|
+
: proto.ClientPayload.UserAgent.Platform.IOS
|
|
46
|
+
: info.business
|
|
29
47
|
? proto.ClientPayload.UserAgent.Platform.SMB_ANDROID
|
|
30
|
-
: proto.ClientPayload.UserAgent.Platform.ANDROID
|
|
48
|
+
: proto.ClientPayload.UserAgent.Platform.ANDROID;
|
|
49
|
+
const userAgent = {
|
|
50
|
+
platform,
|
|
31
51
|
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
|
|
32
52
|
appVersion: version,
|
|
33
|
-
mcc: info.mcc ?? '000',
|
|
34
|
-
mnc: info.mnc ?? '000',
|
|
35
53
|
osVersion: info.osVersion,
|
|
36
54
|
manufacturer: info.manufacturer,
|
|
37
55
|
device: info.device,
|
|
@@ -40,8 +58,10 @@ export function buildMobileLoginPayload(config) {
|
|
|
40
58
|
localeLanguageIso6391: info.localeLanguageIso6391 ?? 'en',
|
|
41
59
|
localeCountryIso31661Alpha2: info.localeCountryIso31661Alpha2 ?? 'US',
|
|
42
60
|
deviceType: proto.ClientPayload.UserAgent.DeviceType.PHONE,
|
|
43
|
-
|
|
44
|
-
|
|
61
|
+
deviceModelType: info.deviceModelType,
|
|
62
|
+
...(isIos
|
|
63
|
+
? { distributionChannel: distributionChannelId(info.distributionChannel) }
|
|
64
|
+
: { mcc: info.mcc ?? '000', mnc: info.mnc ?? '000', deviceBoard: info.deviceBoard })
|
|
45
65
|
};
|
|
46
66
|
return proto.ClientPayload.encode({
|
|
47
67
|
passive: config.passive === true,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bytesToBase64, TEXT_ENCODER } from '../util/bytes.js';
|
|
1
2
|
/** Type guard for an undici-style proxy dispatcher (has `dispatch` method). */
|
|
2
3
|
export function isProxyDispatcher(value) {
|
|
3
4
|
return (typeof value === 'object' &&
|
|
@@ -30,3 +31,85 @@ export function toProxyAgent(proxy) {
|
|
|
30
31
|
}
|
|
31
32
|
return proxy;
|
|
32
33
|
}
|
|
34
|
+
const TCP_PROXY_HINT = 'socketOptions.proxy.ws must hold an http.Agent-style proxy pointing at an http: url (e.g. new HttpProxyAgent("http://host:port")) to tunnel raw TCP';
|
|
35
|
+
/**
|
|
36
|
+
* Resolves the proxy endpoint a raw TCP transport tunnels through with HTTP
|
|
37
|
+
* CONNECT.
|
|
38
|
+
*
|
|
39
|
+
* Returns `undefined` only when no proxy is configured. Proxy shapes the tunnel
|
|
40
|
+
* cannot honour throw instead of resolving to `undefined`, because dropping one
|
|
41
|
+
* silently would dial the destination directly – the opposite of what a
|
|
42
|
+
* deployment that pins its egress to a proxy asked for.
|
|
43
|
+
*/
|
|
44
|
+
export function toTcpProxyEndpoint(proxy) {
|
|
45
|
+
if (!proxy) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (!isProxyAgent(proxy)) {
|
|
49
|
+
if (isProxyDispatcher(proxy)) {
|
|
50
|
+
throw new Error(`undici-style proxy dispatchers cannot tunnel raw TCP – ${TCP_PROXY_HINT}`);
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`unsupported proxy transport – ${TCP_PROXY_HINT}`);
|
|
53
|
+
}
|
|
54
|
+
const url = readAgentProxyUrl(proxy);
|
|
55
|
+
if (url.protocol !== 'http:') {
|
|
56
|
+
throw new Error(`proxy protocol ${url.protocol} is not supported by the raw TCP tunnel – ${TCP_PROXY_HINT}`);
|
|
57
|
+
}
|
|
58
|
+
const username = decodeProxyUserInfo(url.username, 'username');
|
|
59
|
+
const password = decodeProxyUserInfo(url.password, 'password');
|
|
60
|
+
return {
|
|
61
|
+
hostname: url.hostname,
|
|
62
|
+
port: url.port ? Number(url.port) : 80,
|
|
63
|
+
authorization: username || password
|
|
64
|
+
? `Basic ${bytesToBase64(TEXT_ENCODER.encode(`${username}:${password}`))}`
|
|
65
|
+
: undefined
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Throws when {@link toTcpProxyEndpoint} cannot honour `proxy`. */
|
|
69
|
+
export function assertTcpProxySupported(proxy) {
|
|
70
|
+
toTcpProxyEndpoint(proxy);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Decodes one userinfo component of a proxy url. `new URL()` keeps invalid
|
|
74
|
+
* percent escapes verbatim, so a credential holding a literal `%` would reach
|
|
75
|
+
* `decodeURIComponent` and throw a bare `URIError`. The value never reaches the
|
|
76
|
+
* message – only the field name does.
|
|
77
|
+
*/
|
|
78
|
+
function decodeProxyUserInfo(value, field) {
|
|
79
|
+
try {
|
|
80
|
+
return decodeURIComponent(value);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new Error(`proxy url ${field} contains a malformed percent escape`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Reads the proxy url an `http.Agent`-style proxy exposes. Every `*-proxy-agent`
|
|
88
|
+
* package keeps it on `.proxy`, but only the http/https ones store a `URL`
|
|
89
|
+
* there – `socks-proxy-agent` stores a parsed `{ host, port, type }` endpoint.
|
|
90
|
+
*/
|
|
91
|
+
function readAgentProxyUrl(agent) {
|
|
92
|
+
const value = agent.proxy;
|
|
93
|
+
if (value instanceof URL) {
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
if (typeof value === 'string') {
|
|
97
|
+
try {
|
|
98
|
+
return new URL(value);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw new Error(`proxy agent exposes an unparseable proxy url ${JSON.stringify(value)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (isSocksProxyEndpoint(value)) {
|
|
105
|
+
throw new Error(`socks proxy agents cannot tunnel raw TCP – ${TCP_PROXY_HINT}`);
|
|
106
|
+
}
|
|
107
|
+
throw new Error(`proxy agent exposes no proxy url – ${TCP_PROXY_HINT}`);
|
|
108
|
+
}
|
|
109
|
+
/** Matches the `{ host, port, type }` endpoint `socks-proxy-agent` keeps on `.proxy`. */
|
|
110
|
+
function isSocksProxyEndpoint(value) {
|
|
111
|
+
return (typeof value === 'object' &&
|
|
112
|
+
value !== null &&
|
|
113
|
+
typeof value.host === 'string' &&
|
|
114
|
+
typeof value.port === 'number');
|
|
115
|
+
}
|