zapo-js 1.8.1 → 1.9.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/dist/auth/credentials-flow.js +2 -3
- 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/auth/credentials-flow.js +3 -4
- package/dist/esm/client/coordinators/WaOfflineResumeCoordinator.js +84 -3
- package/dist/esm/message/crypto/phash.js +65 -20
- package/dist/esm/message/encode/content.js +4 -1
- 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/message/crypto/phash.d.ts +12 -0
- package/dist/message/crypto/phash.js +65 -20
- package/dist/message/encode/content.js +4 -1
- 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/package.json +1 -1
|
@@ -113,9 +113,7 @@ async function buildCommsConfig(logger, credentials, socketOptions, clientOption
|
|
|
113
113
|
assertValidVersion(resolvedVersion, Boolean(effectiveMobileTransport));
|
|
114
114
|
}
|
|
115
115
|
if (effectiveMobileTransport) {
|
|
116
|
-
|
|
117
|
-
throw new Error('mobileTransport does not support socketOptions.proxy.ws – remove the proxy option or open an issue to add TCP proxy support');
|
|
118
|
-
}
|
|
116
|
+
(0, proxy_1.assertTcpProxySupported)(wsProxy);
|
|
119
117
|
if (!loginIdentity) {
|
|
120
118
|
throw new Error('mobileTransport requires registered credentials (meJid) – run the mobile bridge flow first');
|
|
121
119
|
}
|
|
@@ -138,6 +136,7 @@ async function buildCommsConfig(logger, credentials, socketOptions, clientOption
|
|
|
138
136
|
return {
|
|
139
137
|
url: effectiveMobileTransport.tcpUrl ?? 'tcp://g.whatsapp.net:443',
|
|
140
138
|
rawWebSocketConstructor: WaMobileTcpSocket_1.WaMobileTcpSocketCtor,
|
|
139
|
+
agent: (0, proxy_1.toProxyAgent)(wsProxy),
|
|
141
140
|
connectTimeoutMs: socketOptions.connectTimeoutMs,
|
|
142
141
|
reconnectIntervalMs: socketOptions.reconnectIntervalMs,
|
|
143
142
|
timeoutIntervalMs: socketOptions.timeoutIntervalMs,
|
|
@@ -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 {
|
|
@@ -5,7 +5,7 @@ import { getLoginIdentity } from '../protocol/jid.js';
|
|
|
5
5
|
import { createAndStoreInitialKeys } from '../signal/index.js';
|
|
6
6
|
import { WaMobileTcpSocketCtor } from '../transport/node/WaMobileTcpSocket.js';
|
|
7
7
|
import { buildMobileLoginPayload } from '../transport/noise/WaMobileClientPayload.js';
|
|
8
|
-
import { toProxyAgent, toProxyDispatcher } from '../transport/proxy.js';
|
|
8
|
+
import { assertTcpProxySupported, toProxyAgent, toProxyDispatcher } from '../transport/proxy.js';
|
|
9
9
|
import { parseOptionalInt, toError } from '../util/primitives.js';
|
|
10
10
|
export async function loadOrCreateCredentials(args) {
|
|
11
11
|
args.logger.trace('auth credentials loadOrCreate start');
|
|
@@ -108,9 +108,7 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
|
|
|
108
108
|
assertValidVersion(resolvedVersion, Boolean(effectiveMobileTransport));
|
|
109
109
|
}
|
|
110
110
|
if (effectiveMobileTransport) {
|
|
111
|
-
|
|
112
|
-
throw new Error('mobileTransport does not support socketOptions.proxy.ws – remove the proxy option or open an issue to add TCP proxy support');
|
|
113
|
-
}
|
|
111
|
+
assertTcpProxySupported(wsProxy);
|
|
114
112
|
if (!loginIdentity) {
|
|
115
113
|
throw new Error('mobileTransport requires registered credentials (meJid) – run the mobile bridge flow first');
|
|
116
114
|
}
|
|
@@ -133,6 +131,7 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
|
|
|
133
131
|
return {
|
|
134
132
|
url: effectiveMobileTransport.tcpUrl ?? 'tcp://g.whatsapp.net:443',
|
|
135
133
|
rawWebSocketConstructor: WaMobileTcpSocketCtor,
|
|
134
|
+
agent: toProxyAgent(wsProxy),
|
|
136
135
|
connectTimeoutMs: socketOptions.connectTimeoutMs,
|
|
137
136
|
reconnectIntervalMs: socketOptions.reconnectIntervalMs,
|
|
138
137
|
timeoutIntervalMs: socketOptions.timeoutIntervalMs,
|
|
@@ -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) {
|
|
@@ -154,7 +154,8 @@ export function unwrapMessage(message) {
|
|
|
154
154
|
msg.viewOnceMessageV2?.message ??
|
|
155
155
|
msg.documentWithCaptionMessage?.message ??
|
|
156
156
|
msg.groupStatusMessage?.message ??
|
|
157
|
-
msg.groupStatusMessageV2?.message
|
|
157
|
+
msg.groupStatusMessageV2?.message ??
|
|
158
|
+
msg.botForwardedMessage?.message;
|
|
158
159
|
if (!inner)
|
|
159
160
|
return msg;
|
|
160
161
|
msg = inner;
|
|
@@ -202,6 +203,8 @@ function resolveMessageTypeAttrFrom(msg) {
|
|
|
202
203
|
msg.pollResultSnapshotMessageV3 ||
|
|
203
204
|
msg.templateButtonReplyMessage ||
|
|
204
205
|
msg.messageHistoryNotice ||
|
|
206
|
+
msg.richResponseMessage ||
|
|
207
|
+
msg.botForwardedMessage ||
|
|
205
208
|
msg.secretEncryptedMessage?.secretEncType ===
|
|
206
209
|
proto.Message.SecretEncryptedMessage.SecretEncType.MESSAGE_EDIT ||
|
|
207
210
|
msg.secretEncryptedMessage?.secretEncType ===
|
|
@@ -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
|
+
}
|
|
@@ -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) {
|
|
@@ -180,7 +180,8 @@ function unwrapMessage(message) {
|
|
|
180
180
|
msg.viewOnceMessageV2?.message ??
|
|
181
181
|
msg.documentWithCaptionMessage?.message ??
|
|
182
182
|
msg.groupStatusMessage?.message ??
|
|
183
|
-
msg.groupStatusMessageV2?.message
|
|
183
|
+
msg.groupStatusMessageV2?.message ??
|
|
184
|
+
msg.botForwardedMessage?.message;
|
|
184
185
|
if (!inner)
|
|
185
186
|
return msg;
|
|
186
187
|
msg = inner;
|
|
@@ -228,6 +229,8 @@ function resolveMessageTypeAttrFrom(msg) {
|
|
|
228
229
|
msg.pollResultSnapshotMessageV3 ||
|
|
229
230
|
msg.templateButtonReplyMessage ||
|
|
230
231
|
msg.messageHistoryNotice ||
|
|
232
|
+
msg.richResponseMessage ||
|
|
233
|
+
msg.botForwardedMessage ||
|
|
231
234
|
msg.secretEncryptedMessage?.secretEncType ===
|
|
232
235
|
_proto_1.proto.Message.SecretEncryptedMessage.SecretEncType.MESSAGE_EDIT ||
|
|
233
236
|
msg.secretEncryptedMessage?.secretEncType ===
|
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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 (!
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
31
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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;
|
package/dist/transport/proxy.js
CHANGED
|
@@ -5,6 +5,9 @@ exports.isProxyAgent = isProxyAgent;
|
|
|
5
5
|
exports.isProxyTransport = isProxyTransport;
|
|
6
6
|
exports.toProxyDispatcher = toProxyDispatcher;
|
|
7
7
|
exports.toProxyAgent = toProxyAgent;
|
|
8
|
+
exports.toTcpProxyEndpoint = toTcpProxyEndpoint;
|
|
9
|
+
exports.assertTcpProxySupported = assertTcpProxySupported;
|
|
10
|
+
const bytes_1 = require("../util/bytes");
|
|
8
11
|
/** Type guard for an undici-style proxy dispatcher (has `dispatch` method). */
|
|
9
12
|
function isProxyDispatcher(value) {
|
|
10
13
|
return (typeof value === 'object' &&
|
|
@@ -37,3 +40,85 @@ function toProxyAgent(proxy) {
|
|
|
37
40
|
}
|
|
38
41
|
return proxy;
|
|
39
42
|
}
|
|
43
|
+
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';
|
|
44
|
+
/**
|
|
45
|
+
* Resolves the proxy endpoint a raw TCP transport tunnels through with HTTP
|
|
46
|
+
* CONNECT.
|
|
47
|
+
*
|
|
48
|
+
* Returns `undefined` only when no proxy is configured. Proxy shapes the tunnel
|
|
49
|
+
* cannot honour throw instead of resolving to `undefined`, because dropping one
|
|
50
|
+
* silently would dial the destination directly – the opposite of what a
|
|
51
|
+
* deployment that pins its egress to a proxy asked for.
|
|
52
|
+
*/
|
|
53
|
+
function toTcpProxyEndpoint(proxy) {
|
|
54
|
+
if (!proxy) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
if (!isProxyAgent(proxy)) {
|
|
58
|
+
if (isProxyDispatcher(proxy)) {
|
|
59
|
+
throw new Error(`undici-style proxy dispatchers cannot tunnel raw TCP – ${TCP_PROXY_HINT}`);
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`unsupported proxy transport – ${TCP_PROXY_HINT}`);
|
|
62
|
+
}
|
|
63
|
+
const url = readAgentProxyUrl(proxy);
|
|
64
|
+
if (url.protocol !== 'http:') {
|
|
65
|
+
throw new Error(`proxy protocol ${url.protocol} is not supported by the raw TCP tunnel – ${TCP_PROXY_HINT}`);
|
|
66
|
+
}
|
|
67
|
+
const username = decodeProxyUserInfo(url.username, 'username');
|
|
68
|
+
const password = decodeProxyUserInfo(url.password, 'password');
|
|
69
|
+
return {
|
|
70
|
+
hostname: url.hostname,
|
|
71
|
+
port: url.port ? Number(url.port) : 80,
|
|
72
|
+
authorization: username || password
|
|
73
|
+
? `Basic ${(0, bytes_1.bytesToBase64)(bytes_1.TEXT_ENCODER.encode(`${username}:${password}`))}`
|
|
74
|
+
: undefined
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** Throws when {@link toTcpProxyEndpoint} cannot honour `proxy`. */
|
|
78
|
+
function assertTcpProxySupported(proxy) {
|
|
79
|
+
toTcpProxyEndpoint(proxy);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Decodes one userinfo component of a proxy url. `new URL()` keeps invalid
|
|
83
|
+
* percent escapes verbatim, so a credential holding a literal `%` would reach
|
|
84
|
+
* `decodeURIComponent` and throw a bare `URIError`. The value never reaches the
|
|
85
|
+
* message – only the field name does.
|
|
86
|
+
*/
|
|
87
|
+
function decodeProxyUserInfo(value, field) {
|
|
88
|
+
try {
|
|
89
|
+
return decodeURIComponent(value);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new Error(`proxy url ${field} contains a malformed percent escape`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Reads the proxy url an `http.Agent`-style proxy exposes. Every `*-proxy-agent`
|
|
97
|
+
* package keeps it on `.proxy`, but only the http/https ones store a `URL`
|
|
98
|
+
* there – `socks-proxy-agent` stores a parsed `{ host, port, type }` endpoint.
|
|
99
|
+
*/
|
|
100
|
+
function readAgentProxyUrl(agent) {
|
|
101
|
+
const value = agent.proxy;
|
|
102
|
+
if (value instanceof URL) {
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
if (typeof value === 'string') {
|
|
106
|
+
try {
|
|
107
|
+
return new URL(value);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new Error(`proxy agent exposes an unparseable proxy url ${JSON.stringify(value)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (isSocksProxyEndpoint(value)) {
|
|
114
|
+
throw new Error(`socks proxy agents cannot tunnel raw TCP – ${TCP_PROXY_HINT}`);
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`proxy agent exposes no proxy url – ${TCP_PROXY_HINT}`);
|
|
117
|
+
}
|
|
118
|
+
/** Matches the `{ host, port, type }` endpoint `socks-proxy-agent` keeps on `.proxy`. */
|
|
119
|
+
function isSocksProxyEndpoint(value) {
|
|
120
|
+
return (typeof value === 'object' &&
|
|
121
|
+
value !== null &&
|
|
122
|
+
typeof value.host === 'string' &&
|
|
123
|
+
typeof value.port === 'number');
|
|
124
|
+
}
|