zapo-js 1.8.1 → 1.8.2

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.
@@ -15,6 +15,11 @@ export declare class WaOfflineResumeCoordinator {
15
15
  private state;
16
16
  private totalStanzas;
17
17
  private pendingStanzas;
18
+ private batchInFlight;
19
+ private batchRetries;
20
+ private resumeGeneration;
21
+ private lastBatchRequestMs;
22
+ private batchTimeout;
18
23
  private stanzaTimeout;
19
24
  constructor(options: WaOfflineResumeCoordinatorOptions);
20
25
  get isComplete(): boolean;
@@ -24,6 +29,23 @@ export declare class WaOfflineResumeCoordinator {
24
29
  trackOfflineStanza(): void;
25
30
  reset(): void;
26
31
  private completeResume;
32
+ /**
33
+ * Ask the server for the next window of queued stanzas, at most one request
34
+ * per `REQUEST_DEBOUNCE_MS` and never while one is still outstanding. Only a
35
+ * delivered stanza schedules a request, so the loop winds down on its own
36
+ * once the queue dries up; the resume itself ends on the terminal `offline`
37
+ * bulletin or on the stanza timeout, never on the preview counter, which is
38
+ * a progress estimate rather than an authoritative total.
39
+ */
40
+ private scheduleNextBatch;
41
+ private requestOfflineBatch;
42
+ /**
43
+ * A rejected request delivers no stanza, and only a delivered stanza
44
+ * schedules the next one, so without a retry here a single transport blip
45
+ * strands the whole queue until the stanza timeout. `generation` pins the
46
+ * outcome to the resume that issued it: a rejection from a torn-down resume
47
+ * must not clear the current one's in-flight flag or retry on its behalf.
48
+ */
27
49
  private sendOfflineBatch;
28
50
  private resetStanzaTimeout;
29
51
  private clearTimers;
@@ -5,6 +5,8 @@ const offline_1 = require("../../transport/node/builders/offline");
5
5
  const primitives_1 = require("../../util/primitives");
6
6
  const WA_OFFLINE_RESUME = Object.freeze({
7
7
  BATCH_SIZE: 200,
8
+ REQUEST_DEBOUNCE_MS: 100,
9
+ MAX_BATCH_RETRIES: 3,
8
10
  STANZA_TIMEOUT_MS: 60000
9
11
  });
10
12
  const WA_OFFLINE_RESUME_STATE = Object.freeze({
@@ -19,6 +21,11 @@ class WaOfflineResumeCoordinator {
19
21
  this.state = WA_OFFLINE_RESUME_STATE.INIT;
20
22
  this.totalStanzas = 0;
21
23
  this.pendingStanzas = 0;
24
+ this.batchInFlight = false;
25
+ this.batchRetries = 0;
26
+ this.resumeGeneration = 0;
27
+ this.lastBatchRequestMs = 0;
28
+ this.batchTimeout = null;
22
29
  this.stanzaTimeout = null;
23
30
  }
24
31
  get isComplete() {
@@ -32,6 +39,10 @@ class WaOfflineResumeCoordinator {
32
39
  this.state = WA_OFFLINE_RESUME_STATE.RESUMING;
33
40
  this.totalStanzas = stanzaCount;
34
41
  this.pendingStanzas = stanzaCount;
42
+ this.batchInFlight = false;
43
+ this.batchRetries = 0;
44
+ this.resumeGeneration += 1;
45
+ this.lastBatchRequestMs = 0;
35
46
  this.logger.info('offline resume started', {
36
47
  totalStanzas: stanzaCount
37
48
  });
@@ -41,7 +52,7 @@ class WaOfflineResumeCoordinator {
41
52
  remainingStanzas: stanzaCount,
42
53
  forced: false
43
54
  });
44
- void this.sendOfflineBatch();
55
+ this.requestOfflineBatch();
45
56
  this.resetStanzaTimeout();
46
57
  }
47
58
  handleOfflineComplete(serverStanzaCount) {
@@ -55,17 +66,24 @@ class WaOfflineResumeCoordinator {
55
66
  return;
56
67
  }
57
68
  this.pendingStanzas = Math.max(0, this.pendingStanzas - 1);
69
+ this.batchInFlight = false;
58
70
  this.resetStanzaTimeout();
71
+ this.scheduleNextBatch();
59
72
  }
60
73
  reset() {
61
74
  this.clearTimers();
62
75
  this.state = WA_OFFLINE_RESUME_STATE.INIT;
63
76
  this.totalStanzas = 0;
64
77
  this.pendingStanzas = 0;
78
+ this.batchInFlight = false;
79
+ this.batchRetries = 0;
80
+ this.resumeGeneration += 1;
81
+ this.lastBatchRequestMs = 0;
65
82
  }
66
83
  completeResume(forced, serverStanzaCount) {
67
84
  this.clearTimers();
68
85
  this.state = WA_OFFLINE_RESUME_STATE.COMPLETE;
86
+ this.batchInFlight = false;
69
87
  this.logger.info('offline resume complete', {
70
88
  totalStanzas: this.totalStanzas,
71
89
  remainingStanzas: this.pendingStanzas,
@@ -79,14 +97,73 @@ class WaOfflineResumeCoordinator {
79
97
  forced
80
98
  });
81
99
  }
82
- async sendOfflineBatch() {
100
+ /**
101
+ * Ask the server for the next window of queued stanzas, at most one request
102
+ * per `REQUEST_DEBOUNCE_MS` and never while one is still outstanding. Only a
103
+ * delivered stanza schedules a request, so the loop winds down on its own
104
+ * once the queue dries up; the resume itself ends on the terminal `offline`
105
+ * bulletin or on the stanza timeout, never on the preview counter, which is
106
+ * a progress estimate rather than an authoritative total.
107
+ */
108
+ scheduleNextBatch() {
109
+ if (this.batchInFlight || this.batchTimeout !== null) {
110
+ return;
111
+ }
112
+ const elapsedMs = Date.now() - this.lastBatchRequestMs;
113
+ if (elapsedMs >= WA_OFFLINE_RESUME.REQUEST_DEBOUNCE_MS) {
114
+ this.requestOfflineBatch();
115
+ return;
116
+ }
117
+ this.batchTimeout = setTimeout(() => {
118
+ this.batchTimeout = null;
119
+ if (this.state === WA_OFFLINE_RESUME_STATE.RESUMING) {
120
+ this.scheduleNextBatch();
121
+ }
122
+ }, WA_OFFLINE_RESUME.REQUEST_DEBOUNCE_MS - elapsedMs);
123
+ }
124
+ requestOfflineBatch() {
125
+ this.batchInFlight = true;
126
+ this.lastBatchRequestMs = Date.now();
127
+ this.logger.debug('offline batch requested', {
128
+ batchSize: WA_OFFLINE_RESUME.BATCH_SIZE,
129
+ remainingStanzas: this.pendingStanzas
130
+ });
131
+ void this.sendOfflineBatch(this.resumeGeneration);
132
+ }
133
+ /**
134
+ * A rejected request delivers no stanza, and only a delivered stanza
135
+ * schedules the next one, so without a retry here a single transport blip
136
+ * strands the whole queue until the stanza timeout. `generation` pins the
137
+ * outcome to the resume that issued it: a rejection from a torn-down resume
138
+ * must not clear the current one's in-flight flag or retry on its behalf.
139
+ */
140
+ async sendOfflineBatch(generation) {
83
141
  try {
84
142
  await this.runtime.sendNode((0, offline_1.buildOfflineBatchNode)(WA_OFFLINE_RESUME.BATCH_SIZE));
143
+ if (generation === this.resumeGeneration) {
144
+ this.batchRetries = 0;
145
+ }
85
146
  }
86
147
  catch (err) {
87
- this.logger.warn('offline batch request failed', {
148
+ if (generation !== this.resumeGeneration ||
149
+ this.state !== WA_OFFLINE_RESUME_STATE.RESUMING) {
150
+ return;
151
+ }
152
+ this.batchInFlight = false;
153
+ this.batchRetries += 1;
154
+ if (this.batchRetries > WA_OFFLINE_RESUME.MAX_BATCH_RETRIES) {
155
+ this.logger.warn('offline batch request failed, giving up', {
156
+ attempts: this.batchRetries,
157
+ remainingStanzas: this.pendingStanzas,
158
+ message: (0, primitives_1.toError)(err).message
159
+ });
160
+ return;
161
+ }
162
+ this.logger.debug('offline batch request failed, retrying', {
163
+ attempt: this.batchRetries,
88
164
  message: (0, primitives_1.toError)(err).message
89
165
  });
166
+ this.scheduleNextBatch();
90
167
  }
91
168
  }
92
169
  resetStanzaTimeout() {
@@ -109,6 +186,10 @@ class WaOfflineResumeCoordinator {
109
186
  clearTimeout(this.stanzaTimeout);
110
187
  this.stanzaTimeout = null;
111
188
  }
189
+ if (this.batchTimeout !== null) {
190
+ clearTimeout(this.batchTimeout);
191
+ this.batchTimeout = null;
192
+ }
112
193
  }
113
194
  }
114
195
  exports.WaOfflineResumeCoordinator = WaOfflineResumeCoordinator;
@@ -1458,9 +1458,19 @@ export interface WaClientEventMap {
1458
1458
  export interface WaOfflineResumeEvent {
1459
1459
  readonly status: 'resuming' | 'complete';
1460
1460
  readonly totalStanzas: number;
1461
- /** `0` on the terminal `'complete'` event. */
1461
+ /**
1462
+ * Stanzas still outstanding against `totalStanzas`, counted down from the
1463
+ * server's preview figure. That figure is an estimate, so a clean finish
1464
+ * can still report a small non-zero remainder. Read it together with
1465
+ * `forced`: a remainder alongside `forced: true` is the case where the
1466
+ * flush was cut short and the rest stays queued for the next connection.
1467
+ */
1462
1468
  readonly remainingStanzas: number;
1463
- /** `true` when triggered by an explicit catch-up request rather than auto-resume on reconnect. */
1469
+ /**
1470
+ * `true` when the resume was closed by the client because the server went
1471
+ * quiet for 60s instead of by the server's terminal `offline` bulletin.
1472
+ * Pair with `remainingStanzas` to tell a clean finish from a cut-short one.
1473
+ */
1464
1474
  readonly forced: boolean;
1465
1475
  }
1466
1476
  export interface WaOfflineThreadPreview {
@@ -2,6 +2,8 @@ import { buildOfflineBatchNode } from '../../transport/node/builders/offline.js'
2
2
  import { toError } from '../../util/primitives.js';
3
3
  const WA_OFFLINE_RESUME = Object.freeze({
4
4
  BATCH_SIZE: 200,
5
+ REQUEST_DEBOUNCE_MS: 100,
6
+ MAX_BATCH_RETRIES: 3,
5
7
  STANZA_TIMEOUT_MS: 60000
6
8
  });
7
9
  const WA_OFFLINE_RESUME_STATE = Object.freeze({
@@ -16,6 +18,11 @@ export class WaOfflineResumeCoordinator {
16
18
  this.state = WA_OFFLINE_RESUME_STATE.INIT;
17
19
  this.totalStanzas = 0;
18
20
  this.pendingStanzas = 0;
21
+ this.batchInFlight = false;
22
+ this.batchRetries = 0;
23
+ this.resumeGeneration = 0;
24
+ this.lastBatchRequestMs = 0;
25
+ this.batchTimeout = null;
19
26
  this.stanzaTimeout = null;
20
27
  }
21
28
  get isComplete() {
@@ -29,6 +36,10 @@ export class WaOfflineResumeCoordinator {
29
36
  this.state = WA_OFFLINE_RESUME_STATE.RESUMING;
30
37
  this.totalStanzas = stanzaCount;
31
38
  this.pendingStanzas = stanzaCount;
39
+ this.batchInFlight = false;
40
+ this.batchRetries = 0;
41
+ this.resumeGeneration += 1;
42
+ this.lastBatchRequestMs = 0;
32
43
  this.logger.info('offline resume started', {
33
44
  totalStanzas: stanzaCount
34
45
  });
@@ -38,7 +49,7 @@ export class WaOfflineResumeCoordinator {
38
49
  remainingStanzas: stanzaCount,
39
50
  forced: false
40
51
  });
41
- void this.sendOfflineBatch();
52
+ this.requestOfflineBatch();
42
53
  this.resetStanzaTimeout();
43
54
  }
44
55
  handleOfflineComplete(serverStanzaCount) {
@@ -52,17 +63,24 @@ export class WaOfflineResumeCoordinator {
52
63
  return;
53
64
  }
54
65
  this.pendingStanzas = Math.max(0, this.pendingStanzas - 1);
66
+ this.batchInFlight = false;
55
67
  this.resetStanzaTimeout();
68
+ this.scheduleNextBatch();
56
69
  }
57
70
  reset() {
58
71
  this.clearTimers();
59
72
  this.state = WA_OFFLINE_RESUME_STATE.INIT;
60
73
  this.totalStanzas = 0;
61
74
  this.pendingStanzas = 0;
75
+ this.batchInFlight = false;
76
+ this.batchRetries = 0;
77
+ this.resumeGeneration += 1;
78
+ this.lastBatchRequestMs = 0;
62
79
  }
63
80
  completeResume(forced, serverStanzaCount) {
64
81
  this.clearTimers();
65
82
  this.state = WA_OFFLINE_RESUME_STATE.COMPLETE;
83
+ this.batchInFlight = false;
66
84
  this.logger.info('offline resume complete', {
67
85
  totalStanzas: this.totalStanzas,
68
86
  remainingStanzas: this.pendingStanzas,
@@ -76,14 +94,73 @@ export class WaOfflineResumeCoordinator {
76
94
  forced
77
95
  });
78
96
  }
79
- async sendOfflineBatch() {
97
+ /**
98
+ * Ask the server for the next window of queued stanzas, at most one request
99
+ * per `REQUEST_DEBOUNCE_MS` and never while one is still outstanding. Only a
100
+ * delivered stanza schedules a request, so the loop winds down on its own
101
+ * once the queue dries up; the resume itself ends on the terminal `offline`
102
+ * bulletin or on the stanza timeout, never on the preview counter, which is
103
+ * a progress estimate rather than an authoritative total.
104
+ */
105
+ scheduleNextBatch() {
106
+ if (this.batchInFlight || this.batchTimeout !== null) {
107
+ return;
108
+ }
109
+ const elapsedMs = Date.now() - this.lastBatchRequestMs;
110
+ if (elapsedMs >= WA_OFFLINE_RESUME.REQUEST_DEBOUNCE_MS) {
111
+ this.requestOfflineBatch();
112
+ return;
113
+ }
114
+ this.batchTimeout = setTimeout(() => {
115
+ this.batchTimeout = null;
116
+ if (this.state === WA_OFFLINE_RESUME_STATE.RESUMING) {
117
+ this.scheduleNextBatch();
118
+ }
119
+ }, WA_OFFLINE_RESUME.REQUEST_DEBOUNCE_MS - elapsedMs);
120
+ }
121
+ requestOfflineBatch() {
122
+ this.batchInFlight = true;
123
+ this.lastBatchRequestMs = Date.now();
124
+ this.logger.debug('offline batch requested', {
125
+ batchSize: WA_OFFLINE_RESUME.BATCH_SIZE,
126
+ remainingStanzas: this.pendingStanzas
127
+ });
128
+ void this.sendOfflineBatch(this.resumeGeneration);
129
+ }
130
+ /**
131
+ * A rejected request delivers no stanza, and only a delivered stanza
132
+ * schedules the next one, so without a retry here a single transport blip
133
+ * strands the whole queue until the stanza timeout. `generation` pins the
134
+ * outcome to the resume that issued it: a rejection from a torn-down resume
135
+ * must not clear the current one's in-flight flag or retry on its behalf.
136
+ */
137
+ async sendOfflineBatch(generation) {
80
138
  try {
81
139
  await this.runtime.sendNode(buildOfflineBatchNode(WA_OFFLINE_RESUME.BATCH_SIZE));
140
+ if (generation === this.resumeGeneration) {
141
+ this.batchRetries = 0;
142
+ }
82
143
  }
83
144
  catch (err) {
84
- this.logger.warn('offline batch request failed', {
145
+ if (generation !== this.resumeGeneration ||
146
+ this.state !== WA_OFFLINE_RESUME_STATE.RESUMING) {
147
+ return;
148
+ }
149
+ this.batchInFlight = false;
150
+ this.batchRetries += 1;
151
+ if (this.batchRetries > WA_OFFLINE_RESUME.MAX_BATCH_RETRIES) {
152
+ this.logger.warn('offline batch request failed, giving up', {
153
+ attempts: this.batchRetries,
154
+ remainingStanzas: this.pendingStanzas,
155
+ message: toError(err).message
156
+ });
157
+ return;
158
+ }
159
+ this.logger.debug('offline batch request failed, retrying', {
160
+ attempt: this.batchRetries,
85
161
  message: toError(err).message
86
162
  });
163
+ this.scheduleNextBatch();
87
164
  }
88
165
  }
89
166
  resetStanzaTimeout() {
@@ -106,5 +183,9 @@ export class WaOfflineResumeCoordinator {
106
183
  clearTimeout(this.stanzaTimeout);
107
184
  this.stanzaTimeout = null;
108
185
  }
186
+ if (this.batchTimeout !== null) {
187
+ clearTimeout(this.batchTimeout);
188
+ this.batchTimeout = null;
189
+ }
109
190
  }
110
191
  }
@@ -9,39 +9,84 @@ const CHAR_AT = 0x40;
9
9
  const CHAR_C = 0x63;
10
10
  const CHAR_U = 0x75;
11
11
  const CHAR_S = 0x73;
12
- const MAX_PARTICIPANTS = 2048;
13
- const PER_WID_BYTES = 96;
14
- const SCRATCH = new Uint8Array(MAX_PARTICIPANTS * PER_WID_BYTES);
15
- const OFFSETS = new Uint32Array(MAX_PARTICIPANTS + 1);
16
- const ORDER = new Uint32Array(MAX_PARTICIPANTS);
12
+ /**
13
+ * Upper bound on the bytes the canonical rewrite can add to a single jid: the
14
+ * `.0:` agent marker plus a device digit when the input carries neither, plus
15
+ * the `c.us` server substitution.
16
+ */
17
+ const CANONICAL_GROWTH_BYTES = 4 + WA_DEFAULTS.HOST_DOMAIN.length;
18
+ /**
19
+ * Largest canonical buffer kept alive between calls (the index arrays scale
20
+ * with it). Lists above this bound are rare enough that a throwaway allocation
21
+ * beats holding the memory for the life of the process.
22
+ */
23
+ const RETAINED_SCRATCH_BYTES = 1024 * 1024;
24
+ const RETAINED = {
25
+ scratch: new Uint8Array(0),
26
+ offsets: new Uint32Array(0),
27
+ order: new Uint32Array(0)
28
+ };
29
+ /**
30
+ * Computes the v2 participant hash (`2:<base64>`) attached to group and
31
+ * broadcast-list fanouts.
32
+ *
33
+ * Every participant is canonicalized to `<user>.0:<device>@<server>`, the set
34
+ * is sorted bytewise and hashed as a single SHA-256 stream. There is no
35
+ * participant ceiling: the canonical buffer grows to fit the list, so a group
36
+ * whose members resolve to tens of thousands of devices still hashes.
37
+ *
38
+ * @param participants device jids, in any order
39
+ * @returns the `2:`-prefixed phash, or `'2:'` when the list is empty
40
+ */
17
41
  export function computePhashV2(participants) {
18
- if (participants.length === 0)
19
- return '2:';
20
42
  const n = participants.length;
21
- if (n > MAX_PARTICIPANTS) {
22
- throw new Error(`phash participant count ${n} exceeds MAX_PARTICIPANTS ${MAX_PARTICIPANTS}`);
43
+ if (n === 0)
44
+ return '2:';
45
+ let requiredBytes = 0;
46
+ for (let i = 0; i < n; i += 1) {
47
+ requiredBytes += participants[i].length + CANONICAL_GROWTH_BYTES;
23
48
  }
49
+ const { scratch, offsets, order } = acquireBuffers(n, requiredBytes);
24
50
  let off = 0;
25
51
  for (let i = 0; i < n; i += 1) {
26
- OFFSETS[i] = off;
27
- const nextOff = writeCanonicalUtf8(SCRATCH, off, participants[i]);
28
- if (nextOff > SCRATCH.length) {
29
- throw new Error(`phash canonical buffer overflow at participant ${i}: needs ${nextOff} bytes, scratch is ${SCRATCH.length}`);
30
- }
31
- off = nextOff;
52
+ offsets[i] = off;
53
+ off = writeCanonicalUtf8(scratch, off, participants[i]);
54
+ }
55
+ offsets[n] = off;
56
+ if (off > scratch.length) {
57
+ throw new Error(`phash canonical buffer overflow: needs ${off} bytes, scratch is ${scratch.length}`);
32
58
  }
33
- OFFSETS[n] = off;
34
59
  for (let i = 0; i < n; i += 1)
35
- ORDER[i] = i;
36
- ORDER.subarray(0, n).sort((a, b) => compareScratchSlice(SCRATCH, OFFSETS, a, b));
60
+ order[i] = i;
61
+ const ranked = order.subarray(0, n);
62
+ ranked.sort((a, b) => compareScratchSlice(scratch, offsets, a, b));
37
63
  const parts = new Array(n);
38
64
  for (let i = 0; i < n; i += 1) {
39
- const idx = ORDER[i];
40
- parts[i] = SCRATCH.subarray(OFFSETS[idx], OFFSETS[idx + 1]);
65
+ const idx = ranked[i];
66
+ parts[i] = scratch.subarray(offsets[idx], offsets[idx + 1]);
41
67
  }
42
68
  const digest = sha256(parts);
43
69
  return `2:${bytesToBase64(digest.subarray(0, PHASH_DIGEST_PREFIX))}`;
44
70
  }
71
+ function acquireBuffers(participantCount, requiredBytes) {
72
+ if (requiredBytes > RETAINED_SCRATCH_BYTES) {
73
+ return {
74
+ scratch: new Uint8Array(requiredBytes),
75
+ offsets: new Uint32Array(participantCount + 1),
76
+ order: new Uint32Array(participantCount)
77
+ };
78
+ }
79
+ if (RETAINED.scratch.length < requiredBytes) {
80
+ const nextBytes = Math.max(requiredBytes, RETAINED.scratch.length * 2);
81
+ RETAINED.scratch = new Uint8Array(Math.min(RETAINED_SCRATCH_BYTES, nextBytes));
82
+ }
83
+ if (RETAINED.order.length < participantCount) {
84
+ const capacity = Math.max(participantCount, RETAINED.order.length * 2);
85
+ RETAINED.offsets = new Uint32Array(capacity + 1);
86
+ RETAINED.order = new Uint32Array(capacity);
87
+ }
88
+ return RETAINED;
89
+ }
45
90
  function writeCanonicalUtf8(out, start, jid) {
46
91
  const atIndex = jid.indexOf('@');
47
92
  if (atIndex < 1 || atIndex >= jid.length - 1) {
@@ -1 +1,13 @@
1
+ /**
2
+ * Computes the v2 participant hash (`2:<base64>`) attached to group and
3
+ * broadcast-list fanouts.
4
+ *
5
+ * Every participant is canonicalized to `<user>.0:<device>@<server>`, the set
6
+ * is sorted bytewise and hashed as a single SHA-256 stream. There is no
7
+ * participant ceiling: the canonical buffer grows to fit the list, so a group
8
+ * whose members resolve to tens of thousands of devices still hashes.
9
+ *
10
+ * @param participants device jids, in any order
11
+ * @returns the `2:`-prefixed phash, or `'2:'` when the list is empty
12
+ */
1
13
  export declare function computePhashV2(participants: readonly string[]): string;
@@ -12,39 +12,84 @@ const CHAR_AT = 0x40;
12
12
  const CHAR_C = 0x63;
13
13
  const CHAR_U = 0x75;
14
14
  const CHAR_S = 0x73;
15
- const MAX_PARTICIPANTS = 2048;
16
- const PER_WID_BYTES = 96;
17
- const SCRATCH = new Uint8Array(MAX_PARTICIPANTS * PER_WID_BYTES);
18
- const OFFSETS = new Uint32Array(MAX_PARTICIPANTS + 1);
19
- const ORDER = new Uint32Array(MAX_PARTICIPANTS);
15
+ /**
16
+ * Upper bound on the bytes the canonical rewrite can add to a single jid: the
17
+ * `.0:` agent marker plus a device digit when the input carries neither, plus
18
+ * the `c.us` server substitution.
19
+ */
20
+ const CANONICAL_GROWTH_BYTES = 4 + constants_1.WA_DEFAULTS.HOST_DOMAIN.length;
21
+ /**
22
+ * Largest canonical buffer kept alive between calls (the index arrays scale
23
+ * with it). Lists above this bound are rare enough that a throwaway allocation
24
+ * beats holding the memory for the life of the process.
25
+ */
26
+ const RETAINED_SCRATCH_BYTES = 1024 * 1024;
27
+ const RETAINED = {
28
+ scratch: new Uint8Array(0),
29
+ offsets: new Uint32Array(0),
30
+ order: new Uint32Array(0)
31
+ };
32
+ /**
33
+ * Computes the v2 participant hash (`2:<base64>`) attached to group and
34
+ * broadcast-list fanouts.
35
+ *
36
+ * Every participant is canonicalized to `<user>.0:<device>@<server>`, the set
37
+ * is sorted bytewise and hashed as a single SHA-256 stream. There is no
38
+ * participant ceiling: the canonical buffer grows to fit the list, so a group
39
+ * whose members resolve to tens of thousands of devices still hashes.
40
+ *
41
+ * @param participants device jids, in any order
42
+ * @returns the `2:`-prefixed phash, or `'2:'` when the list is empty
43
+ */
20
44
  function computePhashV2(participants) {
21
- if (participants.length === 0)
22
- return '2:';
23
45
  const n = participants.length;
24
- if (n > MAX_PARTICIPANTS) {
25
- throw new Error(`phash participant count ${n} exceeds MAX_PARTICIPANTS ${MAX_PARTICIPANTS}`);
46
+ if (n === 0)
47
+ return '2:';
48
+ let requiredBytes = 0;
49
+ for (let i = 0; i < n; i += 1) {
50
+ requiredBytes += participants[i].length + CANONICAL_GROWTH_BYTES;
26
51
  }
52
+ const { scratch, offsets, order } = acquireBuffers(n, requiredBytes);
27
53
  let off = 0;
28
54
  for (let i = 0; i < n; i += 1) {
29
- OFFSETS[i] = off;
30
- const nextOff = writeCanonicalUtf8(SCRATCH, off, participants[i]);
31
- if (nextOff > SCRATCH.length) {
32
- throw new Error(`phash canonical buffer overflow at participant ${i}: needs ${nextOff} bytes, scratch is ${SCRATCH.length}`);
33
- }
34
- off = nextOff;
55
+ offsets[i] = off;
56
+ off = writeCanonicalUtf8(scratch, off, participants[i]);
57
+ }
58
+ offsets[n] = off;
59
+ if (off > scratch.length) {
60
+ throw new Error(`phash canonical buffer overflow: needs ${off} bytes, scratch is ${scratch.length}`);
35
61
  }
36
- OFFSETS[n] = off;
37
62
  for (let i = 0; i < n; i += 1)
38
- ORDER[i] = i;
39
- ORDER.subarray(0, n).sort((a, b) => compareScratchSlice(SCRATCH, OFFSETS, a, b));
63
+ order[i] = i;
64
+ const ranked = order.subarray(0, n);
65
+ ranked.sort((a, b) => compareScratchSlice(scratch, offsets, a, b));
40
66
  const parts = new Array(n);
41
67
  for (let i = 0; i < n; i += 1) {
42
- const idx = ORDER[i];
43
- parts[i] = SCRATCH.subarray(OFFSETS[idx], OFFSETS[idx + 1]);
68
+ const idx = ranked[i];
69
+ parts[i] = scratch.subarray(offsets[idx], offsets[idx + 1]);
44
70
  }
45
71
  const digest = (0, core_1.sha256)(parts);
46
72
  return `2:${(0, bytes_1.bytesToBase64)(digest.subarray(0, PHASH_DIGEST_PREFIX))}`;
47
73
  }
74
+ function acquireBuffers(participantCount, requiredBytes) {
75
+ if (requiredBytes > RETAINED_SCRATCH_BYTES) {
76
+ return {
77
+ scratch: new Uint8Array(requiredBytes),
78
+ offsets: new Uint32Array(participantCount + 1),
79
+ order: new Uint32Array(participantCount)
80
+ };
81
+ }
82
+ if (RETAINED.scratch.length < requiredBytes) {
83
+ const nextBytes = Math.max(requiredBytes, RETAINED.scratch.length * 2);
84
+ RETAINED.scratch = new Uint8Array(Math.min(RETAINED_SCRATCH_BYTES, nextBytes));
85
+ }
86
+ if (RETAINED.order.length < participantCount) {
87
+ const capacity = Math.max(participantCount, RETAINED.order.length * 2);
88
+ RETAINED.offsets = new Uint32Array(capacity + 1);
89
+ RETAINED.order = new Uint32Array(capacity);
90
+ }
91
+ return RETAINED;
92
+ }
48
93
  function writeCanonicalUtf8(out, start, jid) {
49
94
  const atIndex = jid.indexOf('@');
50
95
  if (atIndex < 1 || atIndex >= jid.length - 1) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapo-js",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
4
4
  "description": "High-performance WhatsApp Web TypeScript library",
5
5
  "license": "MIT",
6
6
  "author": "vinikjkkj <contact@vinicius.email> (https://github.com/vinikjkkj)",