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
@@ -1,6 +1,6 @@
1
1
  import type { WaPrivacyTokenStore, WaStoredPrivacyTokenRecord } from '../contracts/privacy-token.store';
2
2
  export declare class WaPrivacyTokenMemoryStore implements WaPrivacyTokenStore {
3
- private readonly records;
3
+ protected readonly records: Map<string, WaStoredPrivacyTokenRecord>;
4
4
  private readonly maxEntries;
5
5
  constructor(maxEntries?: number);
6
6
  upsert(record: WaStoredPrivacyTokenRecord): Promise<void>;
@@ -9,5 +9,11 @@ export declare class WaPrivacyTokenMemoryStore implements WaPrivacyTokenStore {
9
9
  deleteByJid(jid: string): Promise<number>;
10
10
  clear(): Promise<void>;
11
11
  destroy(): Promise<void>;
12
+ /** Runs with the jid of every read hit and write. No-op unless a subclass tracks access. */
13
+ protected onEntryAccess(jid: string): void;
14
+ /** Runs with the jid of every deleted or cap-evicted record. */
15
+ protected onEntryRemove(jid: string): void;
16
+ protected onEntriesClear(): void;
17
+ private readonly onEvict;
12
18
  private mergeRecord;
13
19
  }
@@ -1,38 +1,55 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WaPrivacyTokenMemoryStore = void 0;
4
+ const coercion_1 = require("../../util/coercion");
4
5
  const collections_1 = require("../../util/collections");
5
6
  const DEFAULT_MAX_ENTRIES = 10000;
6
7
  class WaPrivacyTokenMemoryStore {
7
- constructor(maxEntries = DEFAULT_MAX_ENTRIES) {
8
+ constructor(maxEntries) {
9
+ this.onEvict = (jid) => this.onEntryRemove(jid);
8
10
  this.records = new Map();
9
- this.maxEntries = maxEntries;
11
+ this.maxEntries = (0, coercion_1.resolvePositive)(maxEntries, DEFAULT_MAX_ENTRIES, 'WaPrivacyTokenMemoryStore.maxEntries');
10
12
  }
11
13
  async upsert(record) {
12
14
  const existing = this.records.get(record.jid);
13
15
  const merged = existing ? this.mergeRecord(existing, record) : record;
14
- (0, collections_1.setBoundedMapEntry)(this.records, record.jid, merged, this.maxEntries);
16
+ (0, collections_1.setBoundedMapEntry)(this.records, record.jid, merged, this.maxEntries, this.onEvict);
17
+ this.onEntryAccess(record.jid);
15
18
  }
16
19
  async upsertBatch(records) {
17
20
  for (let i = 0; i < records.length; i += 1) {
18
21
  const record = records[i];
19
22
  const existing = this.records.get(record.jid);
20
23
  const merged = existing ? this.mergeRecord(existing, record) : record;
21
- (0, collections_1.setBoundedMapEntry)(this.records, record.jid, merged, this.maxEntries);
24
+ (0, collections_1.setBoundedMapEntry)(this.records, record.jid, merged, this.maxEntries, this.onEvict);
25
+ this.onEntryAccess(record.jid);
22
26
  }
23
27
  }
24
28
  async getByJid(jid) {
25
- return this.records.get(jid) ?? null;
29
+ const record = this.records.get(jid);
30
+ if (record === undefined)
31
+ return null;
32
+ this.onEntryAccess(jid);
33
+ return record;
26
34
  }
27
35
  async deleteByJid(jid) {
28
- return this.records.delete(jid) ? 1 : 0;
36
+ if (!this.records.delete(jid))
37
+ return 0;
38
+ this.onEntryRemove(jid);
39
+ return 1;
29
40
  }
30
41
  async clear() {
31
42
  this.records.clear();
43
+ this.onEntriesClear();
32
44
  }
33
45
  async destroy() {
34
- this.records.clear();
46
+ await this.clear();
35
47
  }
48
+ /** Runs with the jid of every read hit and write. No-op unless a subclass tracks access. */
49
+ onEntryAccess(jid) { }
50
+ /** Runs with the jid of every deleted or cap-evicted record. */
51
+ onEntryRemove(jid) { }
52
+ onEntriesClear() { }
36
53
  mergeRecord(existing, incoming) {
37
54
  return {
38
55
  jid: incoming.jid,
@@ -5,8 +5,8 @@ export interface WaSenderKeyMemoryStoreOptions {
5
5
  readonly maxSenderDistributions?: number;
6
6
  }
7
7
  export declare class SenderKeyMemoryStore implements WaSenderKeyStoreContract {
8
- private readonly senderKeys;
9
- private readonly senderDistributions;
8
+ protected readonly senderKeys: Map<string, SenderKeyRecord>;
9
+ protected readonly senderDistributions: Map<string, SenderKeyDistributionRecord>;
10
10
  private readonly maxSenderKeys;
11
11
  private readonly maxSenderDistributions;
12
12
  constructor(options?: WaSenderKeyMemoryStoreOptions);
@@ -22,6 +22,16 @@ export declare class SenderKeyMemoryStore implements WaSenderKeyStoreContract {
22
22
  deleteDeviceSenderKey(target: SignalAddress, groupId?: string): Promise<number>;
23
23
  markForgetSenderKey(groupId: string, participants: readonly SignalAddress[]): Promise<number>;
24
24
  clear(): Promise<void>;
25
+ /**
26
+ * Runs with the map and key of every point-read hit and write (the group
27
+ * scan does not count). No-op unless a subclass tracks access.
28
+ */
29
+ protected onEntryAccess(entries: Map<string, unknown>, key: string): void;
30
+ /** Runs with the map and key of every deleted or cap-evicted entry. */
31
+ protected onEntryRemove(entries: Map<string, unknown>, key: string): void;
32
+ protected onEntriesClear(): void;
33
+ private readonly onSenderKeyEvict;
34
+ private readonly onDistributionEvict;
25
35
  private deleteMatching;
26
36
  private makeKey;
27
37
  }
@@ -10,20 +10,28 @@ const DEFAULT_SENDER_KEY_STORE_LIMITS = Object.freeze({
10
10
  });
11
11
  class SenderKeyMemoryStore {
12
12
  constructor(options = {}) {
13
+ this.onSenderKeyEvict = (key) => this.onEntryRemove(this.senderKeys, key);
14
+ this.onDistributionEvict = (key) => this.onEntryRemove(this.senderDistributions, key);
13
15
  this.senderKeys = new Map();
14
16
  this.senderDistributions = new Map();
15
17
  this.maxSenderKeys = (0, coercion_1.resolvePositive)(options.maxSenderKeys, DEFAULT_SENDER_KEY_STORE_LIMITS.senderKeys, 'WaSenderKeyMemoryStoreOptions.maxSenderKeys');
16
18
  this.maxSenderDistributions = (0, coercion_1.resolvePositive)(options.maxSenderDistributions, DEFAULT_SENDER_KEY_STORE_LIMITS.senderDistributions, 'WaSenderKeyMemoryStoreOptions.maxSenderDistributions');
17
19
  }
18
20
  async upsertSenderKey(record) {
19
- (0, collections_1.setBoundedMapEntry)(this.senderKeys, this.makeKey(record.groupId, record.sender), record, this.maxSenderKeys);
21
+ const key = this.makeKey(record.groupId, record.sender);
22
+ (0, collections_1.setBoundedMapEntry)(this.senderKeys, key, record, this.maxSenderKeys, this.onSenderKeyEvict);
23
+ this.onEntryAccess(this.senderKeys, key);
20
24
  }
21
25
  async upsertSenderKeyDistribution(record) {
22
- (0, collections_1.setBoundedMapEntry)(this.senderDistributions, this.makeKey(record.groupId, record.sender), record, this.maxSenderDistributions);
26
+ const key = this.makeKey(record.groupId, record.sender);
27
+ (0, collections_1.setBoundedMapEntry)(this.senderDistributions, key, record, this.maxSenderDistributions, this.onDistributionEvict);
28
+ this.onEntryAccess(this.senderDistributions, key);
23
29
  }
24
30
  async upsertSenderKeyDistributions(records) {
25
31
  for (const record of records) {
26
- (0, collections_1.setBoundedMapEntry)(this.senderDistributions, this.makeKey(record.groupId, record.sender), record, this.maxSenderDistributions);
32
+ const key = this.makeKey(record.groupId, record.sender);
33
+ (0, collections_1.setBoundedMapEntry)(this.senderDistributions, key, record, this.maxSenderDistributions, this.onDistributionEvict);
34
+ this.onEntryAccess(this.senderDistributions, key);
27
35
  }
28
36
  }
29
37
  async getGroupSenderKeyList(groupId) {
@@ -45,14 +53,25 @@ class SenderKeyMemoryStore {
45
53
  };
46
54
  }
47
55
  async getDeviceSenderKey(groupId, sender) {
48
- const record = this.senderKeys.get(this.makeKey(groupId, sender));
49
- return record ?? null;
56
+ const key = this.makeKey(groupId, sender);
57
+ const record = this.senderKeys.get(key);
58
+ if (record === undefined)
59
+ return null;
60
+ this.onEntryAccess(this.senderKeys, key);
61
+ return record;
50
62
  }
51
63
  async getDeviceSenderKeyDistributions(groupId, senders) {
52
64
  const records = new Array(senders.length);
53
65
  for (let index = 0; index < senders.length; index += 1) {
54
- records[index] =
55
- this.senderDistributions.get(this.makeKey(groupId, senders[index])) ?? null;
66
+ const key = this.makeKey(groupId, senders[index]);
67
+ const record = this.senderDistributions.get(key);
68
+ if (record === undefined) {
69
+ records[index] = null;
70
+ }
71
+ else {
72
+ this.onEntryAccess(this.senderDistributions, key);
73
+ records[index] = record;
74
+ }
56
75
  }
57
76
  return records;
58
77
  }
@@ -74,7 +93,16 @@ class SenderKeyMemoryStore {
74
93
  async clear() {
75
94
  this.senderKeys.clear();
76
95
  this.senderDistributions.clear();
96
+ this.onEntriesClear();
77
97
  }
98
+ /**
99
+ * Runs with the map and key of every point-read hit and write (the group
100
+ * scan does not count). No-op unless a subclass tracks access.
101
+ */
102
+ onEntryAccess(entries, key) { }
103
+ /** Runs with the map and key of every deleted or cap-evicted entry. */
104
+ onEntryRemove(entries, key) { }
105
+ onEntriesClear() { }
78
106
  deleteMatching(map, target, groupId) {
79
107
  let deleted = 0;
80
108
  const targetAddressKey = (0, jid_1.signalAddressKey)(target);
@@ -83,6 +111,7 @@ class SenderKeyMemoryStore {
83
111
  const sameAddress = (0, jid_1.signalAddressKey)(record.sender) === targetAddressKey;
84
112
  if (sameGroup && sameAddress) {
85
113
  map.delete(key);
114
+ this.onEntryRemove(map, key);
86
115
  deleted += 1;
87
116
  }
88
117
  }
@@ -4,7 +4,7 @@ export interface WaSessionMemoryStoreOptions {
4
4
  readonly maxSessions?: number;
5
5
  }
6
6
  export declare class WaSessionMemoryStore implements WaSessionStoreContract {
7
- private readonly signalSessions;
7
+ protected readonly signalSessions: Map<string, SignalSessionRecord>;
8
8
  private readonly maxSessions;
9
9
  constructor(options?: WaSessionMemoryStoreOptions);
10
10
  hasSession(address: SignalAddress): Promise<boolean>;
@@ -18,4 +18,10 @@ export declare class WaSessionMemoryStore implements WaSessionStoreContract {
18
18
  }[]): Promise<void>;
19
19
  deleteSession(address: SignalAddress): Promise<void>;
20
20
  clear(): Promise<void>;
21
+ /** Runs with the key of every read hit and write. No-op unless a subclass tracks access. */
22
+ protected onEntryAccess(key: string): void;
23
+ /** Runs with the key of every deleted or cap-evicted entry. */
24
+ protected onEntryRemove(key: string): void;
25
+ protected onEntriesClear(): void;
26
+ private readonly onEvict;
21
27
  }
@@ -7,43 +7,77 @@ const collections_1 = require("../../util/collections");
7
7
  const DEFAULT_MAX_SESSIONS = 8192;
8
8
  class WaSessionMemoryStore {
9
9
  constructor(options = {}) {
10
+ this.onEvict = (key) => this.onEntryRemove(key);
10
11
  this.signalSessions = new Map();
11
12
  this.maxSessions = (0, coercion_1.resolvePositive)(options.maxSessions, DEFAULT_MAX_SESSIONS, 'WaSessionMemoryStoreOptions.maxSessions');
12
13
  }
13
14
  async hasSession(address) {
14
- return this.signalSessions.has((0, jid_1.signalAddressKey)(address));
15
+ const key = (0, jid_1.signalAddressKey)(address);
16
+ if (!this.signalSessions.has(key))
17
+ return false;
18
+ this.onEntryAccess(key);
19
+ return true;
15
20
  }
16
21
  async hasSessions(addresses) {
17
22
  const result = new Array(addresses.length);
18
23
  for (let i = 0; i < addresses.length; i += 1) {
19
- result[i] = this.signalSessions.has((0, jid_1.signalAddressKey)(addresses[i]));
24
+ const key = (0, jid_1.signalAddressKey)(addresses[i]);
25
+ const found = this.signalSessions.has(key);
26
+ if (found)
27
+ this.onEntryAccess(key);
28
+ result[i] = found;
20
29
  }
21
30
  return result;
22
31
  }
23
32
  async getSession(address) {
24
- return this.signalSessions.get((0, jid_1.signalAddressKey)(address)) ?? null;
33
+ const key = (0, jid_1.signalAddressKey)(address);
34
+ const session = this.signalSessions.get(key);
35
+ if (session === undefined)
36
+ return null;
37
+ this.onEntryAccess(key);
38
+ return session;
25
39
  }
26
40
  async getSessionsBatch(addresses) {
27
41
  const result = new Array(addresses.length);
28
42
  for (let i = 0; i < addresses.length; i += 1) {
29
- result[i] = this.signalSessions.get((0, jid_1.signalAddressKey)(addresses[i])) ?? null;
43
+ const key = (0, jid_1.signalAddressKey)(addresses[i]);
44
+ const session = this.signalSessions.get(key);
45
+ if (session === undefined) {
46
+ result[i] = null;
47
+ }
48
+ else {
49
+ this.onEntryAccess(key);
50
+ result[i] = session;
51
+ }
30
52
  }
31
53
  return result;
32
54
  }
33
55
  async setSession(address, session) {
34
- (0, collections_1.setBoundedMapEntry)(this.signalSessions, (0, jid_1.signalAddressKey)(address), session, this.maxSessions);
56
+ const key = (0, jid_1.signalAddressKey)(address);
57
+ (0, collections_1.setBoundedMapEntry)(this.signalSessions, key, session, this.maxSessions, this.onEvict);
58
+ this.onEntryAccess(key);
35
59
  }
36
60
  async setSessionsBatch(entries) {
37
61
  for (let index = 0; index < entries.length; index += 1) {
38
62
  const entry = entries[index];
39
- (0, collections_1.setBoundedMapEntry)(this.signalSessions, (0, jid_1.signalAddressKey)(entry.address), entry.session, this.maxSessions);
63
+ const key = (0, jid_1.signalAddressKey)(entry.address);
64
+ (0, collections_1.setBoundedMapEntry)(this.signalSessions, key, entry.session, this.maxSessions, this.onEvict);
65
+ this.onEntryAccess(key);
40
66
  }
41
67
  }
42
68
  async deleteSession(address) {
43
- this.signalSessions.delete((0, jid_1.signalAddressKey)(address));
69
+ const key = (0, jid_1.signalAddressKey)(address);
70
+ if (this.signalSessions.delete(key))
71
+ this.onEntryRemove(key);
44
72
  }
45
73
  async clear() {
46
74
  this.signalSessions.clear();
75
+ this.onEntriesClear();
47
76
  }
77
+ /** Runs with the key of every read hit and write. No-op unless a subclass tracks access. */
78
+ onEntryAccess(key) { }
79
+ /** Runs with the key of every deleted or cap-evicted entry. */
80
+ onEntryRemove(key) { }
81
+ onEntriesClear() { }
48
82
  }
49
83
  exports.WaSessionMemoryStore = WaSessionMemoryStore;
@@ -364,10 +364,11 @@ export interface WaCreateStoreOptions<B extends string = string, TBackends exten
364
364
  /**
365
365
  * Opt-in in-process read-through cache in front of a *persistent* backend
366
366
  * for the hot signal domains. Each enabled domain wraps its backend store
367
- * with a bounded-LRU L1 (the in-tree memory provider) so repeated reads of
367
+ * with a bounded L1 (the in-tree memory provider) so repeated reads of
368
368
  * the same peer on the send/recv path skip the backend round-trip; writes
369
369
  * stay write-through (or invalidate-on-write for `privacyToken`) so the
370
- * backend remains authoritative.
370
+ * backend remains authoritative. Entries leave the L1 through the
371
+ * `limits` cap and, when set, the `ttlMs` idle TTL.
371
372
  *
372
373
  * A domain flag is a no-op unless that domain resolves to a real backend
373
374
  * in {@link providers} - caching a `'memory'`/`'none'` provider in front of
@@ -417,8 +418,10 @@ export interface WaCreateStoreOptions<B extends string = string, TBackends exten
417
418
  /** Cache trusted-contact tokens (jid→record). Invalidate-on-write. */
418
419
  readonly privacyToken?: boolean;
419
420
  /**
420
- * Per-domain L1 entry caps (LRU eviction once exceeded). Defaults to
421
- * the matching memory-provider default when unset.
421
+ * Per-domain L1 entry caps. Past the cap the least recently written
422
+ * entry goes or, when the domain has a `ttlMs`, the least recently
423
+ * used one (recency counted in sweep ticks). Defaults to the matching
424
+ * memory-provider default when unset.
422
425
  */
423
426
  readonly limits?: {
424
427
  readonly session?: number;
@@ -426,6 +429,42 @@ export interface WaCreateStoreOptions<B extends string = string, TBackends exten
426
429
  readonly senderKey?: number;
427
430
  readonly privacyToken?: number;
428
431
  };
432
+ /**
433
+ * Per-domain L1 idle TTLs in milliseconds, each a safe integer of at
434
+ * least 1_000. An entry neither read nor written for this long leaves
435
+ * the L1, so a long-lived process keeps the peers it is actively
436
+ * talking to instead of every peer seen since boot. Unset (the
437
+ * default) keeps entries until `limits` evicts them. A TTL on a
438
+ * domain that gets no L1 (flag off, or a `'memory'`/`'none'`
439
+ * provider) is ignored with a warning on the store `logger`.
440
+ *
441
+ * Idle time is counted in ticks of one sweep timer per store, whose
442
+ * period is the smallest `ttlMs / 2` clamped to [1 s, 60 s], so
443
+ * wall-clock steps never expire or keep an entry. An entry goes after
444
+ * at least `ttlMs` idle and less than `ttlMs` plus two periods.
445
+ *
446
+ * The TTL bounds residency, not freshness. The L1 stays coherent with
447
+ * the backend (write-through, or invalidate-on-write), so expiry never
448
+ * changes what a read returns: an expired entry is re-read from the
449
+ * backend on its next use, and the backend rows are never touched.
450
+ * Size it against that miss cost - each expiry turns the next lookup
451
+ * of that peer into one backend read.
452
+ *
453
+ * @example
454
+ * ```ts
455
+ * cacheLayer: {
456
+ * session: true,
457
+ * identity: true,
458
+ * ttlMs: { sessionMs: 30 * 60_000, identityMs: 30 * 60_000 }
459
+ * }
460
+ * ```
461
+ */
462
+ readonly ttlMs?: {
463
+ readonly sessionMs?: number;
464
+ readonly identityMs?: number;
465
+ readonly senderKeyMs?: number;
466
+ readonly privacyTokenMs?: number;
467
+ };
429
468
  };
430
469
  /**
431
470
  * Memory-store tuning for the built-in `'memory'` providers. `limits`
@@ -16,7 +16,9 @@ export declare class WaMobileTcpSocket implements RawWebSocket {
16
16
  private closedReason;
17
17
  private closedClean;
18
18
  private forceCloseTimer;
19
- constructor(url: string, _protocols?: unknown, _options?: WaRawWebSocketInit);
19
+ constructor(url: string, _protocols?: unknown, options?: WaRawWebSocketInit);
20
+ private markOpen;
21
+ private emitMessage;
20
22
  send(data: string | ArrayBuffer | Uint8Array): void;
21
23
  close(code?: number, reason?: string): void;
22
24
  }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WaMobileTcpSocketCtor = exports.WaMobileTcpSocket = void 0;
4
4
  const node_net_1 = require("node:net");
5
5
  const constants_1 = require("../../protocol/constants");
6
+ const proxy_1 = require("../proxy");
6
7
  const bytes_1 = require("../../util/bytes");
7
8
  /**
8
9
  * `RawWebSocket`-shaped adapter over a raw Node TCP socket. Used by the
@@ -10,7 +11,7 @@ const bytes_1 = require("../../util/bytes");
10
11
  * `tcp://host:port` URLs.
11
12
  */
12
13
  class WaMobileTcpSocket {
13
- constructor(url, _protocols, _options) {
14
+ constructor(url, _protocols, options) {
14
15
  this.binaryType = 'arraybuffer';
15
16
  this.readyState = constants_1.WA_READY_STATES.CONNECTING;
16
17
  this.onopen = null;
@@ -22,19 +23,55 @@ class WaMobileTcpSocket {
22
23
  this.closedClean = true;
23
24
  this.forceCloseTimer = null;
24
25
  const { host, port } = parseTcpUrl(url);
25
- this.socket = (0, node_net_1.connect)({ host, port });
26
+ const proxy = (0, proxy_1.toTcpProxyEndpoint)(options?.agent);
27
+ this.socket = proxy
28
+ ? (0, node_net_1.connect)({ host: proxy.hostname, port: proxy.port })
29
+ : (0, node_net_1.connect)({ host, port });
30
+ let tunnelReady = !proxy;
31
+ let proxyResponse = new Uint8Array(0);
26
32
  this.socket.on('connect', () => {
27
33
  if (this.readyState !== constants_1.WA_READY_STATES.CONNECTING)
28
34
  return;
29
- this.readyState = constants_1.WA_READY_STATES.OPEN;
30
- this.onopen?.({});
35
+ if (proxy) {
36
+ const authority = `${host}:${port}`;
37
+ const lines = [
38
+ `CONNECT ${authority} HTTP/1.1`,
39
+ `Host: ${authority}`,
40
+ 'Proxy-Connection: Keep-Alive'
41
+ ];
42
+ if (proxy.authorization)
43
+ lines.push(`Proxy-Authorization: ${proxy.authorization}`);
44
+ this.socket.write(bytes_1.TEXT_ENCODER.encode(`${lines.join('\r\n')}\r\n\r\n`));
45
+ return;
46
+ }
47
+ this.markOpen();
31
48
  });
32
49
  this.socket.on('data', (chunk) => {
33
- if (!this.onmessage || this.readyState !== constants_1.WA_READY_STATES.OPEN)
50
+ if (!tunnelReady && proxy) {
51
+ proxyResponse = (0, bytes_1.concatBytes)([proxyResponse, chunk]);
52
+ const headerEnd = findHttpHeaderEnd(proxyResponse);
53
+ const scanned = headerEnd === -1 ? proxyResponse.byteLength : headerEnd;
54
+ if (scanned > MAX_PROXY_HEADER_BYTES) {
55
+ this.socket.destroy(new Error('WaMobileTcpSocket: proxy response headers too large'));
56
+ return;
57
+ }
58
+ if (headerEnd === -1) {
59
+ return;
60
+ }
61
+ const statusLine = bytes_1.TEXT_DECODER.decode(proxyResponse.subarray(0, headerEnd)).split('\r\n')[0];
62
+ if (!/^HTTP\/1\.[01] 2\d\d(?:\s|$)/.test(statusLine)) {
63
+ this.socket.destroy(new Error(`WaMobileTcpSocket: proxy CONNECT failed (${statusLine})`));
64
+ return;
65
+ }
66
+ const remaining = proxyResponse.subarray(headerEnd + 4);
67
+ proxyResponse = new Uint8Array(0);
68
+ tunnelReady = true;
69
+ this.markOpen();
70
+ if (remaining.byteLength > 0)
71
+ this.emitMessage(remaining);
34
72
  return;
35
- const copy = new Uint8Array(chunk.byteLength);
36
- copy.set(chunk);
37
- this.onmessage({ data: copy });
73
+ }
74
+ this.emitMessage(chunk);
38
75
  });
39
76
  this.socket.on('error', (err) => {
40
77
  this.closedClean = false;
@@ -56,6 +93,19 @@ class WaMobileTcpSocket {
56
93
  });
57
94
  });
58
95
  }
96
+ markOpen() {
97
+ if (this.readyState !== constants_1.WA_READY_STATES.CONNECTING)
98
+ return;
99
+ this.readyState = constants_1.WA_READY_STATES.OPEN;
100
+ this.onopen?.({});
101
+ }
102
+ emitMessage(chunk) {
103
+ if (!this.onmessage || this.readyState !== constants_1.WA_READY_STATES.OPEN)
104
+ return;
105
+ const copy = new Uint8Array(chunk.byteLength);
106
+ copy.set(chunk);
107
+ this.onmessage({ data: copy });
108
+ }
59
109
  send(data) {
60
110
  if (this.readyState !== constants_1.WA_READY_STATES.OPEN) {
61
111
  throw new Error('WaMobileTcpSocket: send() called on non-OPEN socket');
@@ -89,6 +139,16 @@ class WaMobileTcpSocket {
89
139
  }
90
140
  }
91
141
  exports.WaMobileTcpSocket = WaMobileTcpSocket;
142
+ /** Cap on the CONNECT response headers buffered before the tunnel is rejected. */
143
+ const MAX_PROXY_HEADER_BYTES = 65536;
144
+ function findHttpHeaderEnd(bytes) {
145
+ for (let i = 3; i < bytes.byteLength; i += 1) {
146
+ if (bytes[i - 3] === 13 && bytes[i - 2] === 10 && bytes[i - 1] === 13 && bytes[i] === 10) {
147
+ return i - 3;
148
+ }
149
+ }
150
+ return -1;
151
+ }
92
152
  function parseTcpUrl(url) {
93
153
  let work = url;
94
154
  if (work.startsWith('tcp://')) {
@@ -1,5 +1,25 @@
1
1
  import { type Proto } from '../../proto';
2
+ /**
3
+ * Distribution channel advertised by an iOS login (`UserAgent.distributionChannel`).
4
+ * iOS-only – Android leaves the field unset. `appstore` is the normal
5
+ * production install; `testflight`/`internal` mark beta builds.
6
+ */
7
+ export type WaMobileDistributionChannel = 'appstore' | 'website' | 'testflight' | 'internal';
2
8
  export interface WaMobileTransportDeviceInfo {
9
+ /**
10
+ * Operating system the login payload impersonates. `android` (the default,
11
+ * for backwards compatibility) advertises the `ANDROID`/`SMB_ANDROID`
12
+ * platform and fills the Android-only `mcc`/`mnc`/`deviceBoard` fields.
13
+ * `ios` advertises `IOS`/`SMB_IOS`, omits those Android-only fields (the
14
+ * real iPhone client never sends them) and carries `distributionChannel`
15
+ * instead.
16
+ *
17
+ * Field mapping differs by OS and must match the real client:
18
+ * - **Android** `device` = a device codename (e.g. `moto_g52`).
19
+ * - **iOS** `device` = the marketing name (e.g. `iPhone 15 Pro`) while
20
+ * {@link deviceModelType} carries the raw machine id (`iPhone16,1`).
21
+ */
22
+ readonly os?: 'android' | 'ios';
3
23
  readonly manufacturer: string;
4
24
  readonly device: string;
5
25
  readonly osVersion: string;
@@ -12,6 +32,8 @@ export interface WaMobileTransportDeviceInfo {
12
32
  readonly phoneId?: string;
13
33
  readonly deviceBoard?: string;
14
34
  readonly deviceModelType?: string;
35
+ /** iOS distribution channel; ignored for Android. Defaults to `appstore`. */
36
+ readonly distributionChannel?: WaMobileDistributionChannel;
15
37
  readonly business?: boolean;
16
38
  }
17
39
  export interface WaMobileLoginPayloadConfig {
@@ -16,6 +16,20 @@ function parseAppVersion(version) {
16
16
  quaternary: at(3)
17
17
  };
18
18
  }
19
+ function distributionChannelId(channel) {
20
+ const { DistributionChannel } = _proto_1.proto.ClientPayload.UserAgent;
21
+ switch (channel) {
22
+ case 'website':
23
+ return DistributionChannel.WEBSITE;
24
+ case 'testflight':
25
+ return DistributionChannel.TESTFLIGHT;
26
+ case 'internal':
27
+ return DistributionChannel.INTERNAL;
28
+ case 'appstore':
29
+ case undefined:
30
+ return DistributionChannel.APPSTORE;
31
+ }
32
+ }
19
33
  /**
20
34
  * Builds the encoded {@link Proto.ClientPayload} bytes the WhatsApp Mobile
21
35
  * transport sends after the noise login handshake. Throws when
@@ -27,14 +41,18 @@ function buildMobileLoginPayload(config) {
27
41
  }
28
42
  const info = config.deviceInfo;
29
43
  const version = parseAppVersion(info.appVersion);
30
- const userAgent = {
31
- platform: info.business
44
+ const isIos = info.os === 'ios';
45
+ const platform = isIos
46
+ ? info.business
47
+ ? _proto_1.proto.ClientPayload.UserAgent.Platform.SMB_IOS
48
+ : _proto_1.proto.ClientPayload.UserAgent.Platform.IOS
49
+ : info.business
32
50
  ? _proto_1.proto.ClientPayload.UserAgent.Platform.SMB_ANDROID
33
- : _proto_1.proto.ClientPayload.UserAgent.Platform.ANDROID,
51
+ : _proto_1.proto.ClientPayload.UserAgent.Platform.ANDROID;
52
+ const userAgent = {
53
+ platform,
34
54
  releaseChannel: _proto_1.proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
35
55
  appVersion: version,
36
- mcc: info.mcc ?? '000',
37
- mnc: info.mnc ?? '000',
38
56
  osVersion: info.osVersion,
39
57
  manufacturer: info.manufacturer,
40
58
  device: info.device,
@@ -43,8 +61,10 @@ function buildMobileLoginPayload(config) {
43
61
  localeLanguageIso6391: info.localeLanguageIso6391 ?? 'en',
44
62
  localeCountryIso31661Alpha2: info.localeCountryIso31661Alpha2 ?? 'US',
45
63
  deviceType: _proto_1.proto.ClientPayload.UserAgent.DeviceType.PHONE,
46
- deviceBoard: info.deviceBoard,
47
- deviceModelType: info.deviceModelType
64
+ deviceModelType: info.deviceModelType,
65
+ ...(isIos
66
+ ? { distributionChannel: distributionChannelId(info.distributionChannel) }
67
+ : { mcc: info.mcc ?? '000', mnc: info.mnc ?? '000', deviceBoard: info.deviceBoard })
48
68
  };
49
69
  return _proto_1.proto.ClientPayload.encode({
50
70
  passive: config.passive === true,
@@ -9,3 +9,27 @@ export declare function isProxyTransport(value: unknown): value is WaProxyTransp
9
9
  export declare function toProxyDispatcher(proxy: WaProxyTransport | undefined): WaProxyDispatcher | undefined;
10
10
  /** Narrows `proxy` to {@link WaProxyAgent} or returns `undefined`. */
11
11
  export declare function toProxyAgent(proxy: WaProxyTransport | undefined): WaProxyAgent | undefined;
12
+ /**
13
+ * HTTP CONNECT endpoint a raw TCP transport dials to reach its destination.
14
+ *
15
+ * @sensitive Contains proxy credentials (`authorization`). Never log, serialize
16
+ * via `JSON.stringify`, or transmit unencrypted. Persist with encryption-at-rest.
17
+ */
18
+ export interface WaTcpProxyEndpoint {
19
+ readonly hostname: string;
20
+ readonly port: number;
21
+ /** Ready-to-send `Proxy-Authorization` value when the proxy url carries credentials. */
22
+ readonly authorization?: string;
23
+ }
24
+ /**
25
+ * Resolves the proxy endpoint a raw TCP transport tunnels through with HTTP
26
+ * CONNECT.
27
+ *
28
+ * Returns `undefined` only when no proxy is configured. Proxy shapes the tunnel
29
+ * cannot honour throw instead of resolving to `undefined`, because dropping one
30
+ * silently would dial the destination directly – the opposite of what a
31
+ * deployment that pins its egress to a proxy asked for.
32
+ */
33
+ export declare function toTcpProxyEndpoint(proxy: WaProxyTransport | undefined): WaTcpProxyEndpoint | undefined;
34
+ /** Throws when {@link toTcpProxyEndpoint} cannot honour `proxy`. */
35
+ export declare function assertTcpProxySupported(proxy: WaProxyTransport | undefined): void;