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.
- package/dist/client/coordinators/WaOfflineResumeCoordinator.d.ts +22 -0
- package/dist/client/coordinators/WaOfflineResumeCoordinator.js +84 -3
- package/dist/client/types.d.ts +12 -2
- package/dist/esm/client/coordinators/WaOfflineResumeCoordinator.js +84 -3
- package/dist/esm/message/crypto/phash.js +65 -20
- package/dist/message/crypto/phash.d.ts +12 -0
- package/dist/message/crypto/phash.js +65 -20
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/client/types.d.ts
CHANGED
|
@@ -1458,9 +1458,19 @@ export interface WaClientEventMap {
|
|
|
1458
1458
|
export interface WaOfflineResumeEvent {
|
|
1459
1459
|
readonly status: 'resuming' | 'complete';
|
|
1460
1460
|
readonly totalStanzas: number;
|
|
1461
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
22
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
off
|
|
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
|
-
|
|
36
|
-
|
|
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 =
|
|
40
|
-
parts[i] =
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
off
|
|
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
|
-
|
|
39
|
-
|
|
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 =
|
|
43
|
-
parts[i] =
|
|
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) {
|