whalibmob 5.10.1 → 5.10.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -18
- package/cli.js +36 -7
- package/index.js +4 -0
- package/lib/Client.js +80 -37
- package/lib/argo/ArgoDecoder.js +240 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1449,20 +1449,26 @@ client.on('account_restriction', (r) => {
|
|
|
1449
1449
|
> client-side shortens it; the only thing that helps is not sending more
|
|
1450
1450
|
> unanswered first messages while it is on.
|
|
1451
1451
|
|
|
1452
|
-
> [!
|
|
1453
|
-
> **
|
|
1454
|
-
>
|
|
1455
|
-
>
|
|
1456
|
-
>
|
|
1452
|
+
> [!NOTE]
|
|
1453
|
+
> **This transport answers in one of two encodings**, and the reply says which:
|
|
1454
|
+
> JSON, or Argo — Meta's compact binary encoding for GraphQL. Both are read.
|
|
1455
|
+
> WhatsApp sends Argo with its self-describing flag set, so it carries its own
|
|
1456
|
+
> field names and decodes without the GraphQL schema the query was written
|
|
1457
|
+
> against.
|
|
1458
|
+
>
|
|
1459
|
+
> Do not add a `format` attribute to the query in the hope of steering this — it
|
|
1460
|
+
> was tried against a live server, which responded by dropping the stanza
|
|
1461
|
+
> entirely and answering nothing.
|
|
1457
1462
|
>
|
|
1458
|
-
>
|
|
1459
|
-
>
|
|
1460
|
-
>
|
|
1463
|
+
> Some accounts get an answer with no restriction data in it at all — a bare
|
|
1464
|
+
> `false` rather than a result. That throws, with the decoded value on
|
|
1465
|
+
> `err.mexDecoded`, rather than being reported as "not restricted": an
|
|
1466
|
+
> all-clear invented from an answer that never said so would have you send more
|
|
1467
|
+
> messages and lengthen the very restriction you were asking about.
|
|
1461
1468
|
>
|
|
1462
|
-
> The `account_restriction` event
|
|
1463
|
-
> restriction starting and lifting, and those announcements
|
|
1464
|
-
>
|
|
1465
|
-
> told, with the countdown, without ever calling the query.
|
|
1469
|
+
> The `account_restriction` event does not depend on any of this. The server
|
|
1470
|
+
> *announces* a restriction starting and lifting, and those announcements carry
|
|
1471
|
+
> the countdown. Keep a listener attached and you are told either way.
|
|
1466
1472
|
|
|
1467
1473
|
### What Is Automatic vs What You Need to Do
|
|
1468
1474
|
|
|
@@ -3262,19 +3268,37 @@ For the numbers without the countdown:
|
|
|
3262
3268
|
wa> /restriction --once
|
|
3263
3269
|
```
|
|
3264
3270
|
|
|
3265
|
-
|
|
3266
|
-
|
|
3271
|
+
To check the display without waiting to be restricted, `--demo` counts down a
|
|
3272
|
+
made-up restriction. Nothing is sent, nothing is asked of the server, and
|
|
3273
|
+
nothing is left behind when it ends:
|
|
3274
|
+
|
|
3275
|
+
```sh
|
|
3276
|
+
wa> /restriction --demo
|
|
3277
|
+
──────────────────────────────────────────────────
|
|
3278
|
+
status RESTRICTED (demo — not real)
|
|
3279
|
+
reason too many people you messaged blocked or reported you
|
|
3280
|
+
remaining 05:00:00
|
|
3281
|
+
──────────────────────────────────────────────────
|
|
3282
|
+
press any key to stop watching
|
|
3283
|
+
restricted — 04:59:57 remaining
|
|
3284
|
+
```
|
|
3285
|
+
|
|
3286
|
+
`--demo 90` uses ninety seconds instead of the default five hours, which is
|
|
3287
|
+
short enough to watch it reach zero and stop on its own.
|
|
3288
|
+
|
|
3289
|
+
Some accounts get a reply that decodes cleanly but holds no restriction data.
|
|
3290
|
+
The command says that, rather than reading it as an all-clear:
|
|
3267
3291
|
|
|
3268
3292
|
```sh
|
|
3269
3293
|
wa> /restriction
|
|
3270
3294
|
checking account restriction...
|
|
3271
|
-
error: mex query 23983697327930364: the server answered
|
|
3295
|
+
error: mex query 23983697327930364: the server answered false rather than a result — this query returns no data for this account
|
|
3272
3296
|
|
|
3273
|
-
The
|
|
3274
|
-
|
|
3297
|
+
The reply was read successfully — it simply contains no restriction
|
|
3298
|
+
data. This query returns nothing usable for your account.
|
|
3275
3299
|
|
|
3276
3300
|
You will still be told about a restriction: the server announces one when
|
|
3277
|
-
it starts and when it lifts, and those announcements
|
|
3301
|
+
it starts and when it lifts, and those announcements carry the countdown.
|
|
3278
3302
|
```
|
|
3279
3303
|
|
|
3280
3304
|
An account that is fine says so and returns immediately:
|
|
@@ -3794,6 +3818,7 @@ wa> /quit
|
|
|
3794
3818
|
| `/appstate --snapshot` | Re-read all app state from scratch |
|
|
3795
3819
|
| `/restriction` | Account restriction status with a live countdown |
|
|
3796
3820
|
| `/restriction --once` | Restriction status without the countdown |
|
|
3821
|
+
| `/restriction --demo [seconds]` | Fake countdown, to check the display |
|
|
3797
3822
|
| `/limit` | Alias for `/restriction` |
|
|
3798
3823
|
| `/ephemeral <jid> <seconds>` | Set disappearing messages timer for a chat |
|
|
3799
3824
|
| `/ephemeral-default <seconds>` | Set global default ephemeral timer for new chats |
|
package/cli.js
CHANGED
|
@@ -478,6 +478,7 @@ const HELP = `
|
|
|
478
478
|
/appstate --snapshot re-read all of it from scratch
|
|
479
479
|
/restriction account restriction + live countdown
|
|
480
480
|
/restriction --once just the numbers, no countdown
|
|
481
|
+
/restriction --demo [seconds] fake countdown to check the display
|
|
481
482
|
/ephemeral <jid> <seconds> set disappearing timer for chat
|
|
482
483
|
/ephemeral-default <seconds> set default timer for ALL new chats
|
|
483
484
|
/block <jid> block contact
|
|
@@ -1479,6 +1480,34 @@ async function handleLine(line) {
|
|
|
1479
1480
|
|
|
1480
1481
|
case '/restriction':
|
|
1481
1482
|
case '/limit': {
|
|
1483
|
+
// --demo feeds a made-up restriction into this session and counts it
|
|
1484
|
+
// down, so the display can be checked without waiting to be restricted.
|
|
1485
|
+
// Nothing is sent and nothing is asked of the server.
|
|
1486
|
+
const demoAt = p.indexOf('--demo');
|
|
1487
|
+
if (demoAt !== -1) {
|
|
1488
|
+
const secs = parseInt(p[demoAt + 1], 10) || 5 * 3600;
|
|
1489
|
+
const { parseTimelockPayload } = require('./lib/ReachoutTimelock');
|
|
1490
|
+
if (!_client) { fail('/restriction --demo needs a client; use /connect first'); break; }
|
|
1491
|
+
const fake = parseTimelockPayload({
|
|
1492
|
+
is_active: true,
|
|
1493
|
+
time_enforcement_ends: String(Math.floor(Date.now() / 1000) + secs),
|
|
1494
|
+
enforcement_type: 'BIZ_QUALITY'
|
|
1495
|
+
});
|
|
1496
|
+
const shown = _client._setReachoutTimelock(fake, 'demo');
|
|
1497
|
+
hr();
|
|
1498
|
+
kv('status', 'RESTRICTED (demo — not real)');
|
|
1499
|
+
kv('reason', shown.reason);
|
|
1500
|
+
kv('remaining', shown.remaining);
|
|
1501
|
+
hr();
|
|
1502
|
+
_rl && _rl.pause();
|
|
1503
|
+
await countdown(_client, shown);
|
|
1504
|
+
_rl && _rl.resume();
|
|
1505
|
+
// Leave nothing behind that a later check could mistake for real.
|
|
1506
|
+
_client._reachoutTimelock = null;
|
|
1507
|
+
out(' demo over — nothing was recorded');
|
|
1508
|
+
break;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1482
1511
|
requireConn();
|
|
1483
1512
|
const watch = !p.includes('--once');
|
|
1484
1513
|
out('checking account restriction...');
|
|
@@ -1487,19 +1516,19 @@ async function handleLine(line) {
|
|
|
1487
1516
|
st = await _client.fetchReachoutTimelock();
|
|
1488
1517
|
} catch (e) {
|
|
1489
1518
|
fail(e.message);
|
|
1490
|
-
if (e.
|
|
1519
|
+
if (e.mexDecoded !== undefined) {
|
|
1491
1520
|
out('');
|
|
1492
|
-
out(' The
|
|
1493
|
-
out('
|
|
1521
|
+
out(' The reply was read successfully — it simply contains no restriction');
|
|
1522
|
+
out(' data. This query returns nothing usable for your account.');
|
|
1494
1523
|
out('');
|
|
1495
1524
|
out(' You will still be told about a restriction: the server announces one when');
|
|
1496
|
-
out(' it starts and when it lifts, and those announcements
|
|
1525
|
+
out(' it starts and when it lifts, and those announcements carry the countdown.');
|
|
1497
1526
|
out(' Leave the session connected and watch for:');
|
|
1498
1527
|
out(' ACCOUNT RESTRICTED — <reason>, HH:MM:SS remaining');
|
|
1528
|
+
} else if (e.mexFormat) {
|
|
1499
1529
|
out('');
|
|
1500
|
-
out(' The
|
|
1501
|
-
out('
|
|
1502
|
-
out(' is what makes this format readable without its schema.');
|
|
1530
|
+
out(' The query worked, but the reply is in an encoding that could not be');
|
|
1531
|
+
out(' decoded. Paste the bytes above when reporting this.');
|
|
1503
1532
|
} else {
|
|
1504
1533
|
out('');
|
|
1505
1534
|
out(' If the reply is sketched above, paste that line when reporting this —');
|
package/index.js
CHANGED
|
@@ -14,6 +14,7 @@ const { MessageSender, makeJid, generateMessageId } = require('./lib/messages/Me
|
|
|
14
14
|
const { GroupParticipantResult, GroupJoinRequest } = require('./lib/GroupParticipant');
|
|
15
15
|
const { AppStateStore, COLLECTIONS: APP_STATE_COLLECTIONS } = require('./lib/appstate/AppStateStore');
|
|
16
16
|
const ReachoutTimelock = require('./lib/ReachoutTimelock');
|
|
17
|
+
const { decodeArgo, tryDecodeArgo } = require('./lib/argo/ArgoDecoder');
|
|
17
18
|
const AppStateSync = require('./lib/appstate/AppStateSync');
|
|
18
19
|
const { PATCH_INTEGRITY: LT_HASH } = require('./lib/appstate/LTHash');
|
|
19
20
|
const {
|
|
@@ -113,6 +114,9 @@ module.exports = {
|
|
|
113
114
|
LT_HASH,
|
|
114
115
|
// Account restriction
|
|
115
116
|
ReachoutTimelock,
|
|
117
|
+
// Argo (the binary encoding the GraphQL transport may answer in)
|
|
118
|
+
decodeArgo,
|
|
119
|
+
tryDecodeArgo,
|
|
116
120
|
// Auth utilities
|
|
117
121
|
makeCacheableSignalKeyStore,
|
|
118
122
|
addTransactionCapability,
|
package/lib/Client.js
CHANGED
|
@@ -14,6 +14,7 @@ const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = requi
|
|
|
14
14
|
const { BinaryNode } = require('./BinaryNode');
|
|
15
15
|
const { AppStateStore, COLLECTIONS } = require('./appstate/AppStateStore');
|
|
16
16
|
const ReachoutTimelock = require('./ReachoutTimelock');
|
|
17
|
+
const { decodeArgo } = require('./argo/ArgoDecoder');
|
|
17
18
|
const AppStateSync = require('./appstate/AppStateSync');
|
|
18
19
|
const SyncdProto = require('./appstate/SyncdProto');
|
|
19
20
|
const AppMutations = require('./appstate/Mutations');
|
|
@@ -4961,32 +4962,18 @@ class WhalibmobClient extends EventEmitter {
|
|
|
4961
4962
|
async _mexQuery(queryId, variables, dataPath) {
|
|
4962
4963
|
const body = Buffer.from(JSON.stringify({ variables: variables || {} }), 'utf8');
|
|
4963
4964
|
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
xmlns: 'w:mex'
|
|
4977
|
-
}, [new BinaryNode('query', attrs, body)]));
|
|
4978
|
-
};
|
|
4979
|
-
|
|
4980
|
-
const hinted = this._mexJsonHintWorks !== false;
|
|
4981
|
-
let resp = await send(hinted);
|
|
4982
|
-
|
|
4983
|
-
// The hint is the only thing that changed, so a refusal means the server
|
|
4984
|
-
// does not accept it. Drop it for this session and ask again plainly.
|
|
4985
|
-
if (hinted && resp && resp.attrs && resp.attrs.type === 'error') {
|
|
4986
|
-
this._mexJsonHintWorks = false;
|
|
4987
|
-
_whaDbg('[DBG] MEX format=json hint refused — retrying without it');
|
|
4988
|
-
resp = await send(false);
|
|
4989
|
-
}
|
|
4965
|
+
// The <query> node carries the query id and nothing else.
|
|
4966
|
+
//
|
|
4967
|
+
// Asking for a format here was tried and is wrong: a `format="json"`
|
|
4968
|
+
// attribute makes the server drop the stanza on the floor — no reply at
|
|
4969
|
+
// all, where without it there is at least an answer. It does not ignore
|
|
4970
|
+
// what it does not recognise, so nothing speculative belongs on this node.
|
|
4971
|
+
const resp = await this._sendIq(new BinaryNode('iq', {
|
|
4972
|
+
id: this._genMsgId(),
|
|
4973
|
+
to: 's.whatsapp.net',
|
|
4974
|
+
type: 'get',
|
|
4975
|
+
xmlns: 'w:mex'
|
|
4976
|
+
}, [new BinaryNode('query', { query_id: String(queryId) }, body)]));
|
|
4990
4977
|
|
|
4991
4978
|
if (!resp) throw new Error('mex query ' + queryId + ': no reply from server (IQ timed out)');
|
|
4992
4979
|
|
|
@@ -5013,15 +5000,56 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5013
5000
|
return dataPath ? (json.data && json.data[dataPath]) : json.data;
|
|
5014
5001
|
}
|
|
5015
5002
|
|
|
5016
|
-
//
|
|
5017
|
-
//
|
|
5018
|
-
//
|
|
5003
|
+
// Not JSON, so the other encoding this transport uses: Argo, which is what
|
|
5004
|
+
// newer servers answer with. It carries its own field names, so it decodes
|
|
5005
|
+
// without the GraphQL schema the query was written against.
|
|
5019
5006
|
const resultNode = findChildDeep(resp, 'result');
|
|
5020
5007
|
const format = resultNode && resultNode.attrs && resultNode.attrs.format;
|
|
5008
|
+
const raw = resultNode ? getNodeContent(resultNode) : null;
|
|
5009
|
+
|
|
5010
|
+
// Only when the server said so. Guessing that unlabelled bytes are Argo
|
|
5011
|
+
// turns "this reply is not what I expected" into a misleading complaint
|
|
5012
|
+
// about a format that was never claimed.
|
|
5013
|
+
if (raw && raw.length && format && String(format).toLowerCase() === 'argo') {
|
|
5014
|
+
let decoded;
|
|
5015
|
+
try {
|
|
5016
|
+
decoded = decodeArgo(raw);
|
|
5017
|
+
} catch (e) {
|
|
5018
|
+
const err = new Error('mex query ' + queryId + ': the reply is ' +
|
|
5019
|
+
(format ? '"' + format + '"' : 'binary') + ' and could not be decoded — ' +
|
|
5020
|
+
e.message + ' (' + raw.length + ' bytes: ' + raw.toString('hex') + ')');
|
|
5021
|
+
err.mexFormat = format ? String(format) : null;
|
|
5022
|
+
err.mexPayload = raw;
|
|
5023
|
+
throw err;
|
|
5024
|
+
}
|
|
5025
|
+
_whaDbg('[DBG] MEX_ARGO decoded ' + JSON.stringify(decoded));
|
|
5026
|
+
|
|
5027
|
+
if (decoded && typeof decoded === 'object') {
|
|
5028
|
+
if (decoded.errors && decoded.errors.length) {
|
|
5029
|
+
throw new Error('mex query ' + queryId + ': ' +
|
|
5030
|
+
decoded.errors.map(e => (e && e.message) || 'unknown error').join(', '));
|
|
5031
|
+
}
|
|
5032
|
+
if (decoded.data !== undefined) {
|
|
5033
|
+
return dataPath ? (decoded.data && decoded.data[dataPath]) : decoded.data;
|
|
5034
|
+
}
|
|
5035
|
+
return dataPath ? decoded[dataPath] : decoded;
|
|
5036
|
+
}
|
|
5037
|
+
|
|
5038
|
+
// The server answered something that is not a GraphQL result at all —
|
|
5039
|
+
// a bare boolean, most often. There is no data in it to return, and
|
|
5040
|
+
// saying so beats handing back a shape the caller will misread.
|
|
5041
|
+
const err = new Error('mex query ' + queryId + ': the server answered ' +
|
|
5042
|
+
JSON.stringify(decoded) + ' rather than a result — this query returns ' +
|
|
5043
|
+
'no data for this account');
|
|
5044
|
+
err.mexFormat = format ? String(format) : null;
|
|
5045
|
+
err.mexPayload = raw;
|
|
5046
|
+
err.mexDecoded = decoded;
|
|
5047
|
+
throw err;
|
|
5048
|
+
}
|
|
5049
|
+
|
|
5021
5050
|
if (format && String(format).toLowerCase() !== 'json') {
|
|
5022
|
-
const raw = getNodeContent(resultNode);
|
|
5023
5051
|
const err = new Error('mex query ' + queryId + ': the server answered in "' +
|
|
5024
|
-
format + '"
|
|
5052
|
+
format + '", which this library cannot read' +
|
|
5025
5053
|
(raw ? ' (' + raw.length + ' bytes: ' + raw.toString('hex') + ')' : ''));
|
|
5026
5054
|
err.mexFormat = String(format);
|
|
5027
5055
|
err.mexPayload = raw || null;
|
|
@@ -5094,13 +5122,27 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5094
5122
|
}
|
|
5095
5123
|
|
|
5096
5124
|
// Internal: parse newsletter response from w:mex IQ result
|
|
5125
|
+
//
|
|
5126
|
+
// Every newsletter call travels the same transport as the restriction query,
|
|
5127
|
+
// so a server that answers that one in Argo answers these in Argo too. This
|
|
5128
|
+
// used to swallow that and return null, which reads as "no such newsletter"
|
|
5129
|
+
// rather than "this reply is in an encoding nothing here can read" — the
|
|
5130
|
+
// difference between a shrug and something a caller can act on.
|
|
5097
5131
|
_parseNewsletterResponse(resp) {
|
|
5098
5132
|
try {
|
|
5099
|
-
const
|
|
5100
|
-
if (!
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5133
|
+
const json = this._mexJsonFrom(resp);
|
|
5134
|
+
if (!json) {
|
|
5135
|
+
const resultNode = findChildDeep(resp, 'result');
|
|
5136
|
+
const format = resultNode && resultNode.attrs && resultNode.attrs.format;
|
|
5137
|
+
if (format && String(format).toLowerCase() !== 'json') {
|
|
5138
|
+
_whaDbg('[DBG] NEWSLETTER reply format=' + format + ' — cannot decode');
|
|
5139
|
+
const err = new Error('the server answered in "' + format +
|
|
5140
|
+
'" rather than JSON, which this library cannot read');
|
|
5141
|
+
err.mexFormat = String(format);
|
|
5142
|
+
throw err;
|
|
5143
|
+
}
|
|
5144
|
+
return null;
|
|
5145
|
+
}
|
|
5104
5146
|
const nl = json && (json.newsletter || (json.data && json.data.xwa2_newsletter));
|
|
5105
5147
|
if (!nl) return null;
|
|
5106
5148
|
const meta = nl.metadata || nl;
|
|
@@ -5111,7 +5153,8 @@ class WhalibmobClient extends EventEmitter {
|
|
|
5111
5153
|
subscriberCount: nl.subscribers_count || meta.subscriber_count || 0,
|
|
5112
5154
|
creationTime: nl.creation_time || meta.creation_time || 0
|
|
5113
5155
|
};
|
|
5114
|
-
} catch (
|
|
5156
|
+
} catch (err) {
|
|
5157
|
+
if (err && err.mexFormat) throw err;
|
|
5115
5158
|
return null;
|
|
5116
5159
|
}
|
|
5117
5160
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Argo — the binary encoding WhatsApp's GraphQL transport answers in.
|
|
4
|
+
//
|
|
5
|
+
// A w:mex reply can come back as JSON or as Argo; the <result> node says which.
|
|
6
|
+
// Argo is normally decoded against the schema of the query that asked, which a
|
|
7
|
+
// client outside Meta does not have. But a message can set a SelfDescribing
|
|
8
|
+
// flag, and then it carries its own field names and types — which is what
|
|
9
|
+
// WhatsApp sends, and what makes this readable at all.
|
|
10
|
+
//
|
|
11
|
+
// Only the self-describing form is implemented. A schema-typed message is
|
|
12
|
+
// refused rather than guessed at.
|
|
13
|
+
//
|
|
14
|
+
// Layout:
|
|
15
|
+
// [header bitset][block][block]…[core]
|
|
16
|
+
// where each block is a length label followed by that many bytes, and the core
|
|
17
|
+
// is the last one. Values in the core are Labels — zigzag ULEB128 integers —
|
|
18
|
+
// that either carry a small value outright or point into a block.
|
|
19
|
+
|
|
20
|
+
// ─── labels ──────────────────────────────────────────────────────────────────
|
|
21
|
+
//
|
|
22
|
+
// A label is one signed integer doing several jobs at once. Non-negative is a
|
|
23
|
+
// length or a boolean; the three values below are markers; anything lower is a
|
|
24
|
+
// backreference to a value already seen.
|
|
25
|
+
const LABEL_NULL = -1;
|
|
26
|
+
const LABEL_ABSENT = -2;
|
|
27
|
+
const LABEL_ERROR = -3;
|
|
28
|
+
// -4 is the first backreference and means "the first value in this block".
|
|
29
|
+
const BACKREF_BASE = -4;
|
|
30
|
+
|
|
31
|
+
// Self-describing type markers, as label values.
|
|
32
|
+
const MARKER = {
|
|
33
|
+
NULL: -1,
|
|
34
|
+
FALSE: 0,
|
|
35
|
+
TRUE: 1,
|
|
36
|
+
OBJECT: 2,
|
|
37
|
+
LIST: 3,
|
|
38
|
+
STRING: 4,
|
|
39
|
+
BYTES: 5,
|
|
40
|
+
INT: 6,
|
|
41
|
+
FLOAT: 7
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Header flag bit positions.
|
|
45
|
+
const FLAG_INLINE_EVERYTHING = 0;
|
|
46
|
+
const FLAG_SELF_DESCRIBING = 1;
|
|
47
|
+
|
|
48
|
+
class Reader {
|
|
49
|
+
constructor(buf) {
|
|
50
|
+
this.buf = buf;
|
|
51
|
+
this.pos = 0;
|
|
52
|
+
}
|
|
53
|
+
get done() { return this.pos >= this.buf.length; }
|
|
54
|
+
byte() {
|
|
55
|
+
if (this.pos >= this.buf.length) throw new Error('argo: ran off the end of the message');
|
|
56
|
+
return this.buf[this.pos++];
|
|
57
|
+
}
|
|
58
|
+
peek() { return this.buf[this.pos]; }
|
|
59
|
+
take(n) {
|
|
60
|
+
if (n < 0 || this.pos + n > this.buf.length) {
|
|
61
|
+
throw new Error('argo: asked for ' + n + ' bytes with ' +
|
|
62
|
+
(this.buf.length - this.pos) + ' left');
|
|
63
|
+
}
|
|
64
|
+
const out = this.buf.slice(this.pos, this.pos + n);
|
|
65
|
+
this.pos += n;
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
// ULEB128, then zigzag back to signed.
|
|
69
|
+
label() {
|
|
70
|
+
let value = 0n, shift = 0n;
|
|
71
|
+
for (;;) {
|
|
72
|
+
const b = this.byte();
|
|
73
|
+
value |= BigInt(b & 0x7f) << shift;
|
|
74
|
+
if (!(b & 0x80)) break;
|
|
75
|
+
shift += 7n;
|
|
76
|
+
if (shift > 63n) throw new Error('argo: label is too long to be a number');
|
|
77
|
+
}
|
|
78
|
+
const signed = (value >> 1n) ^ -(value & 1n);
|
|
79
|
+
return Number(signed);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readHeader(reader) {
|
|
84
|
+
let bits = 0, shift = 0, more = true;
|
|
85
|
+
while (more) {
|
|
86
|
+
const b = reader.byte();
|
|
87
|
+
bits |= (b >> 1) << shift;
|
|
88
|
+
shift += 7;
|
|
89
|
+
more = (b & 1) === 1;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
bits,
|
|
93
|
+
selfDescribing: !!(bits >> FLAG_SELF_DESCRIBING & 1),
|
|
94
|
+
inlineEverything: !!(bits >> FLAG_INLINE_EVERYTHING & 1)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A block holds the bulky values — strings, byte arrays, numbers — with the
|
|
99
|
+
// core referring to them in order. Blocks are handed out in the order their
|
|
100
|
+
// types are first needed, which is why they are taken from a queue rather than
|
|
101
|
+
// looked up by name.
|
|
102
|
+
class BlockQueue {
|
|
103
|
+
constructor(blocks, core, inlineEverything) {
|
|
104
|
+
this.blocks = blocks;
|
|
105
|
+
this.core = core;
|
|
106
|
+
this.inline = inlineEverything;
|
|
107
|
+
this.next = 0;
|
|
108
|
+
this.byKey = new Map();
|
|
109
|
+
}
|
|
110
|
+
for(key) {
|
|
111
|
+
if (this.inline) return { reader: this.core, seen: [] };
|
|
112
|
+
if (!this.byKey.has(key)) {
|
|
113
|
+
const buf = this.blocks[this.next++];
|
|
114
|
+
if (buf === undefined) throw new Error('argo: message is missing a block for ' + key);
|
|
115
|
+
this.byKey.set(key, { reader: new Reader(buf), seen: [] });
|
|
116
|
+
}
|
|
117
|
+
return this.byKey.get(key);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function decodeArgo(bytes) {
|
|
122
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
123
|
+
const reader = new Reader(buf);
|
|
124
|
+
const header = readHeader(reader);
|
|
125
|
+
|
|
126
|
+
if (!header.selfDescribing) {
|
|
127
|
+
const err = new Error('argo: this message is encoded against a GraphQL schema ' +
|
|
128
|
+
'rather than describing itself, so it cannot be read without that schema');
|
|
129
|
+
err.needsSchema = true;
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const blocks = [];
|
|
134
|
+
if (header.inlineEverything) {
|
|
135
|
+
blocks.push(reader.take(buf.length - reader.pos));
|
|
136
|
+
} else {
|
|
137
|
+
while (!reader.done) {
|
|
138
|
+
const len = reader.label();
|
|
139
|
+
if (len < 0) throw new Error('argo: block length cannot be ' + len);
|
|
140
|
+
blocks.push(reader.take(len));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!blocks.length) throw new Error('argo: message has no core');
|
|
144
|
+
|
|
145
|
+
// The core is the last block, and every other block feeds it.
|
|
146
|
+
const core = new Reader(blocks[blocks.length - 1]);
|
|
147
|
+
const queue = new BlockQueue(blocks.slice(0, -1), core, header.inlineEverything);
|
|
148
|
+
|
|
149
|
+
return readValue(core, queue);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// One length-prefixed value out of a block, with backreferences resolved.
|
|
153
|
+
//
|
|
154
|
+
// The same string written twice is written once and pointed at the second time,
|
|
155
|
+
// which is most of why this format is small. A backreference that points at
|
|
156
|
+
// nothing means the message and our reading of it have diverged.
|
|
157
|
+
function readBlockValue(core, queue, key, convert) {
|
|
158
|
+
const block = queue.for(key);
|
|
159
|
+
const label = core.label();
|
|
160
|
+
if (label <= BACKREF_BASE) {
|
|
161
|
+
const idx = -label + BACKREF_BASE;
|
|
162
|
+
if (idx < 0 || idx >= block.seen.length) {
|
|
163
|
+
throw new Error('argo: backreference ' + label + ' points outside ' + key);
|
|
164
|
+
}
|
|
165
|
+
return block.seen[idx];
|
|
166
|
+
}
|
|
167
|
+
if (label < 0) throw new Error('argo: ' + key + ' cannot have label ' + label);
|
|
168
|
+
const value = convert(block.reader.take(label));
|
|
169
|
+
block.seen.push(value);
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function readValue(core, queue) {
|
|
174
|
+
const marker = core.label();
|
|
175
|
+
|
|
176
|
+
switch (marker) {
|
|
177
|
+
case MARKER.NULL: return null;
|
|
178
|
+
case MARKER.FALSE: return false;
|
|
179
|
+
case MARKER.TRUE: return true;
|
|
180
|
+
|
|
181
|
+
case MARKER.OBJECT: {
|
|
182
|
+
const count = core.label();
|
|
183
|
+
if (count < 0) throw new Error('argo: object cannot have ' + count + ' fields');
|
|
184
|
+
const out = {};
|
|
185
|
+
for (let i = 0; i < count; i++) {
|
|
186
|
+
const name = readBlockValue(core, queue, 'String', b => b.toString('utf8'));
|
|
187
|
+
out[name] = readValue(core, queue);
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case MARKER.LIST: {
|
|
193
|
+
const count = core.label();
|
|
194
|
+
if (count < 0) throw new Error('argo: list cannot have ' + count + ' items');
|
|
195
|
+
const out = [];
|
|
196
|
+
for (let i = 0; i < count; i++) out.push(readValue(core, queue));
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
case MARKER.STRING: return readBlockValue(core, queue, 'String', b => b.toString('utf8'));
|
|
201
|
+
case MARKER.BYTES: return readBlockValue(core, queue, 'Bytes', b => Buffer.from(b));
|
|
202
|
+
|
|
203
|
+
// An integer lives entirely in its block — the core spends nothing on it.
|
|
204
|
+
case MARKER.INT: {
|
|
205
|
+
const block = queue.for('Varint');
|
|
206
|
+
return block.reader.label();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case MARKER.FLOAT: {
|
|
210
|
+
const block = queue.for('Float');
|
|
211
|
+
return block.reader.take(8).readDoubleLE(0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
default:
|
|
215
|
+
if (marker === LABEL_ABSENT) return undefined;
|
|
216
|
+
if (marker === LABEL_ERROR) return readValue(core, queue);
|
|
217
|
+
throw new Error('argo: unknown type marker ' + marker);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Decode an Argo message, or return null when it is not one this can read.
|
|
223
|
+
*
|
|
224
|
+
* Never throws: a caller reaching for this already has a reply it could not
|
|
225
|
+
* parse, and a second failure on top of that is not news. `decodeArgo` is there
|
|
226
|
+
* for when the reason matters.
|
|
227
|
+
*/
|
|
228
|
+
function tryDecodeArgo(bytes) {
|
|
229
|
+
try { return decodeArgo(bytes); }
|
|
230
|
+
catch (_) { return null; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = {
|
|
234
|
+
decodeArgo,
|
|
235
|
+
tryDecodeArgo,
|
|
236
|
+
MARKER,
|
|
237
|
+
LABEL_NULL,
|
|
238
|
+
LABEL_ABSENT,
|
|
239
|
+
LABEL_ERROR
|
|
240
|
+
};
|