tunnelfetch 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +617 -0
  3. package/README.zh-CN.md +470 -0
  4. package/package.json +74 -0
  5. package/src/client/cookies.js +429 -0
  6. package/src/client/decode.js +346 -0
  7. package/src/client/redirect.js +249 -0
  8. package/src/client.js +704 -0
  9. package/src/errors.js +181 -0
  10. package/src/http1/chunked.js +289 -0
  11. package/src/http1/index.js +10 -0
  12. package/src/http1/request.js +143 -0
  13. package/src/http1/response.js +493 -0
  14. package/src/http2/connection.js +1170 -0
  15. package/src/http2/constants.js +129 -0
  16. package/src/http2/frames.js +291 -0
  17. package/src/http2/hpack.js +420 -0
  18. package/src/http2/huffman.js +203 -0
  19. package/src/http2/index.js +21 -0
  20. package/src/index.js +46 -0
  21. package/src/pool.js +256 -0
  22. package/src/proxy/direct.js +62 -0
  23. package/src/proxy/http-connect.js +206 -0
  24. package/src/proxy/index.js +197 -0
  25. package/src/proxy/socks5.js +344 -0
  26. package/src/tls/aead.js +263 -0
  27. package/src/tls/connect.js +407 -0
  28. package/src/tls/constants.js +334 -0
  29. package/src/tls/extensions.js +376 -0
  30. package/src/tls/handshake-messages.js +901 -0
  31. package/src/tls/handshake.js +568 -0
  32. package/src/tls/handshake12.js +507 -0
  33. package/src/tls/index.js +44 -0
  34. package/src/tls/keyschedule.js +473 -0
  35. package/src/tls/record.js +872 -0
  36. package/src/tls/tickets.js +145 -0
  37. package/src/tls/transcript.js +101 -0
  38. package/src/tls/wire.js +224 -0
  39. package/src/transport.js +296 -0
  40. package/src/trust/der.js +551 -0
  41. package/src/trust/index.js +375 -0
  42. package/src/trust/name.js +235 -0
  43. package/src/trust/ocsp.js +759 -0
  44. package/src/trust/path.js +595 -0
  45. package/src/trust/roots.js +454 -0
  46. package/src/trust/x509.js +902 -0
  47. package/src/util/bytes.js +470 -0
  48. package/src/util/deadline.js +266 -0
  49. package/src/warmup-fixture.js +85 -0
  50. package/src/warmup.js +243 -0
  51. package/types/client/cookies.d.ts +159 -0
  52. package/types/client/decode.d.ts +54 -0
  53. package/types/client/redirect.d.ts +96 -0
  54. package/types/client.d.ts +323 -0
  55. package/types/errors.d.ts +141 -0
  56. package/types/http1/chunked.d.ts +48 -0
  57. package/types/http1/index.d.ts +3 -0
  58. package/types/http1/request.d.ts +44 -0
  59. package/types/http1/response.d.ts +183 -0
  60. package/types/http2/connection.d.ts +282 -0
  61. package/types/http2/constants.d.ts +95 -0
  62. package/types/http2/frames.d.ts +116 -0
  63. package/types/http2/hpack.d.ts +99 -0
  64. package/types/http2/huffman.d.ts +21 -0
  65. package/types/http2/index.d.ts +5 -0
  66. package/types/index.d.ts +17 -0
  67. package/types/pool.d.ts +135 -0
  68. package/types/proxy/direct.d.ts +26 -0
  69. package/types/proxy/http-connect.d.ts +37 -0
  70. package/types/proxy/index.d.ts +62 -0
  71. package/types/proxy/socks5.d.ts +47 -0
  72. package/types/tls/aead.d.ts +67 -0
  73. package/types/tls/connect.d.ts +280 -0
  74. package/types/tls/constants.d.ts +275 -0
  75. package/types/tls/extensions.d.ts +195 -0
  76. package/types/tls/handshake-messages.d.ts +430 -0
  77. package/types/tls/handshake.d.ts +90 -0
  78. package/types/tls/handshake12.d.ts +35 -0
  79. package/types/tls/index.d.ts +9 -0
  80. package/types/tls/keyschedule.d.ts +272 -0
  81. package/types/tls/record.d.ts +361 -0
  82. package/types/tls/tickets.d.ts +66 -0
  83. package/types/tls/transcript.d.ts +52 -0
  84. package/types/tls/wire.d.ts +106 -0
  85. package/types/transport.d.ts +222 -0
  86. package/types/trust/der.d.ts +239 -0
  87. package/types/trust/index.d.ts +194 -0
  88. package/types/trust/name.d.ts +33 -0
  89. package/types/trust/ocsp.d.ts +138 -0
  90. package/types/trust/path.d.ts +139 -0
  91. package/types/trust/roots.d.ts +36 -0
  92. package/types/trust/x509.d.ts +401 -0
  93. package/types/util/bytes.d.ts +183 -0
  94. package/types/util/deadline.d.ts +133 -0
  95. package/types/warmup-fixture.d.ts +11 -0
  96. package/types/warmup.d.ts +45 -0
@@ -0,0 +1,145 @@
1
+ // The resumption ticket store: per-Client, keyed like the connection pool, single-use.
2
+ //
3
+ // A ticket is a credential. Presenting one tells the server "treat this connection as a
4
+ // continuation of the session you issued it on", and the server will — without sending a
5
+ // certificate, without this client re-validating anything. So the one property this store must
6
+ // never lose is that a ticket can only ever be OFFERED under the exact configuration it was
7
+ // OBTAINED under. The pool solved the identical problem for sockets: poolKey() folds scheme,
8
+ // host, port, proxy, trust policy and TLS options into the key precisely so a connection
9
+ // validated under one policy cannot serve a request that asked for another. Tickets are keyed
10
+ // by the caller with that same key, for that same reason. Keying by hostname alone is the
11
+ // canonical mistake here: it lets a request that asked for certificate pinning resume — and
12
+ // silently skip the pin check — on the strength of a session that was never pinned at all.
13
+ //
14
+ // Three smaller policies, each deliberate:
15
+ //
16
+ // * Single use. A ticket is consumed by take() whether or not the resumption succeeds.
17
+ // RFC 8446 appendix C.4 wants tickets used at most once (reuse lets a passive observer
18
+ // correlate connections), servers commonly enforce it, and a second offer of a spent ticket
19
+ // would burn a round trip to be declined. Servers that want chains of resumption issue a
20
+ // fresh ticket on every connection, including resumed ones.
21
+ // * Lifetimes are honoured on both ends: a ticket_lifetime of zero is discarded on arrival
22
+ // (s4.6.1 says exactly that), everything is capped at the protocol's seven-day ceiling
23
+ // (clients MUST NOT cache longer, whatever the server claimed), and take() re-checks age at
24
+ // the moment of use, so a ticket that expired while stored is dropped, not offered.
25
+ // * The clock is injected. Ages are wall-clock quantities, and on the target runtime the
26
+ // clock is frozen within a synchronous slice — tests need to move time, and nothing else in
27
+ // the TLS layer reads a clock at all.
28
+
29
+ import { TlsError, codes } from '../errors.js';
30
+ import { hashLength } from './keyschedule.js';
31
+
32
+ /** RFC 8446 s4.6.1: servers MUST NOT exceed this, and clients MUST NOT cache beyond it. */
33
+ const MAX_LIFETIME_SEC = 604800; // seven days
34
+
35
+ export class TicketStore {
36
+ /**
37
+ * @param {object} [opts]
38
+ * @param {number} [opts.maxPerKey] tickets retained per key, default 2 — the number a typical
39
+ * server flight issues; older tickets are evicted first
40
+ * @param {() => number} [opts.now] epoch-ms source, injectable for tests
41
+ */
42
+ constructor({ maxPerKey = 2, now = () => Date.now() } = {}) {
43
+ /** @type {Map<string, Array<StoredTicket>>} */
44
+ this._byKey = new Map();
45
+ this._maxPerKey = maxPerKey;
46
+ this._now = now;
47
+ }
48
+
49
+ /**
50
+ * @typedef {object} StoredTicket
51
+ * @property {Uint8Array} identity
52
+ * @property {Uint8Array} psk
53
+ * @property {import('./keyschedule.js').ScheduleHash} hash
54
+ * @property {number} ageAdd
55
+ * @property {number} lifetimeMs already clamped to the seven-day ceiling
56
+ * @property {number} receivedAtMs
57
+ * @property {object} peer
58
+ * @property {number} cipherSuite
59
+ */
60
+
61
+ /** Total tickets currently held, for tests and diagnostics. */
62
+ get size() {
63
+ let n = 0;
64
+ for (const list of this._byKey.values()) n += list.length;
65
+ return n;
66
+ }
67
+
68
+ /**
69
+ * Store a captured ticket under `key`. Returns whether it was retained: a zero (or negative)
70
+ * lifetime is a server instruction to discard immediately and is honoured silently — that is
71
+ * the server's prerogative, not an error — while a structurally unusable capture (no PSK, no
72
+ * identity, unknown hash) throws, because the only producer is this package's own driver and
73
+ * a malformed capture is a bug, not an input.
74
+ *
75
+ * @param {string} key the pool key of the connection the ticket arrived on
76
+ * @param {import('./connect.js').CapturedTicket} captured
77
+ * @returns {boolean}
78
+ */
79
+ put(key, captured) {
80
+ const { identity, psk, hash, lifetimeSec, ageAdd } = captured;
81
+ hashLength(hash); // throws on anything but SHA-256/SHA-384
82
+ if (!(identity instanceof Uint8Array) || identity.byteLength === 0 ||
83
+ !(psk instanceof Uint8Array) || psk.byteLength === 0) {
84
+ throw new TlsError(codes.TLS_TICKET,
85
+ 'a captured ticket needs a non-empty identity and PSK; refusing to store a blank credential');
86
+ }
87
+ if (!Number.isInteger(lifetimeSec) || !Number.isInteger(ageAdd)) {
88
+ throw new TlsError(codes.TLS_TICKET,
89
+ `ticket lifetime ${lifetimeSec} / age_add ${ageAdd} are not integers`,
90
+ { lifetimeSec, ageAdd });
91
+ }
92
+ if (lifetimeSec <= 0) return false; // s4.6.1: zero means discard immediately
93
+ const lifetimeMs = Math.min(lifetimeSec, MAX_LIFETIME_SEC) * 1000;
94
+ const list = this._byKey.get(key) ?? [];
95
+ list.push({
96
+ identity, psk, hash, ageAdd, lifetimeMs,
97
+ receivedAtMs: this._now(),
98
+ peer: captured.peer ?? null,
99
+ cipherSuite: captured.cipherSuite,
100
+ });
101
+ while (list.length > this._maxPerKey) list.shift();
102
+ this._byKey.set(key, list);
103
+ return true;
104
+ }
105
+
106
+ /**
107
+ * Take the freshest usable ticket for `key` as a ready-to-offer PSK, or null. The ticket is
108
+ * removed either way it goes from here — single use — and expired tickets encountered on the
109
+ * way are dropped rather than offered: a server checks obfuscated_ticket_age against the
110
+ * lifetime it granted, and offering a stale ticket is a round trip spent being refused.
111
+ *
112
+ * @param {string} key MUST be built from the same inputs as the connection's pool key; this
113
+ * equality is the entire trust story of resumption (see the module comment)
114
+ * @returns {import('./connect.js').ResumptionOffer | null}
115
+ */
116
+ take(key) {
117
+ const list = this._byKey.get(key);
118
+ if (!list) return null;
119
+ while (list.length > 0) {
120
+ const t = /** @type {StoredTicket} */ (list.pop());
121
+ const age = this._now() - t.receivedAtMs;
122
+ if (age >= t.lifetimeMs) continue; // expired in storage: drop it, try the next-newest
123
+ if (list.length === 0) this._byKey.delete(key);
124
+ return {
125
+ identity: t.identity,
126
+ psk: t.psk,
127
+ hash: t.hash,
128
+ peer: t.peer,
129
+ // s4.2.11.1: obfuscated_ticket_age = age-in-ms + ticket_age_add, mod 2^32, current at
130
+ // the moment each hello is built (a HelloRetryRequest builds a second one later).
131
+ obfuscatedTicketAge: () => {
132
+ const ms = Math.max(0, this._now() - t.receivedAtMs);
133
+ return (ms + t.ageAdd) % 0x100000000;
134
+ },
135
+ };
136
+ }
137
+ this._byKey.delete(key);
138
+ return null;
139
+ }
140
+
141
+ /** Drop everything; a closed Client must not keep credentials alive. */
142
+ clear() {
143
+ this._byKey.clear();
144
+ }
145
+ }
@@ -0,0 +1,101 @@
1
+ // Running handshake transcript hash (RFC 8446 s4.4.1).
2
+ //
3
+ // WebCrypto has no streaming digest, so the raw handshake messages are buffered and re-digested
4
+ // on every hash() call. That tradeoff is deliberate: the transcript covers handshake messages
5
+ // only, which a real handshake bounds at a few hundred KB of certificates, and hash() is called
6
+ // a handful of times per connection — buffering costs one transcript-sized allocation, while a
7
+ // hand-rolled incremental SHA-2 in JS would be a fresh piece of security-critical code to get
8
+ // wrong. The buffer is still capped so a peer streaming an absurd certificate chain cannot make
9
+ // us hold unbounded memory; the record layer's per-message cap fires first in practice.
10
+
11
+ import { TlsError, codes } from '../errors.js';
12
+ import { concat, u8, u24 } from '../util/bytes.js';
13
+ import { HANDSHAKE_TYPE } from './constants.js';
14
+ import { hashLength } from './keyschedule.js';
15
+
16
+ export class Transcript {
17
+ /**
18
+ * @param {import('./keyschedule.js').ScheduleHash} hash fixed once the cipher suite is known
19
+ * @param {{ maxBytes?: number }} [opts] transcript buffer cap, default 1 MiB
20
+ */
21
+ constructor(hash, { maxBytes = 1 << 20 } = {}) {
22
+ hashLength(hash); // validate eagerly: a typo'd hash name must not surface at first hash()
23
+ this._hash = hash;
24
+ /** @type {Uint8Array[]} */
25
+ this._chunks = [];
26
+ this._len = 0;
27
+ this._maxBytes = maxBytes;
28
+ /** @type {Uint8Array | null} digest cache, invalidated by update() */
29
+ this._cached = null;
30
+ }
31
+
32
+ get bytesBuffered() {
33
+ return this._len;
34
+ }
35
+
36
+ /**
37
+ * Append raw handshake message bytes (including the 4-byte message header — the transcript
38
+ * is over complete Handshake structs, never record framing).
39
+ * @param {Uint8Array} bytes
40
+ */
41
+ update(bytes) {
42
+ if (bytes.byteLength === 0) return;
43
+ if (this._len + bytes.byteLength > this._maxBytes) {
44
+ throw new TlsError(codes.TLS_HANDSHAKE,
45
+ `handshake transcript would exceed ${this._maxBytes} bytes`,
46
+ { buffered: this._len, adding: bytes.byteLength, limit: this._maxBytes });
47
+ }
48
+ // Copy: callers hand us subarrays aliasing record buffers they will reuse.
49
+ this._chunks.push(bytes.slice());
50
+ this._len += bytes.byteLength;
51
+ this._cached = null;
52
+ }
53
+
54
+ /**
55
+ * Digest of everything appended so far. Does not consume; call as often as needed.
56
+ * @returns {Promise<Uint8Array>}
57
+ */
58
+ async hash() {
59
+ if (!this._cached) {
60
+ this._cached = new Uint8Array(
61
+ await crypto.subtle.digest(this._hash, concat(this._chunks, this._len)),
62
+ );
63
+ }
64
+ return this._cached;
65
+ }
66
+
67
+ /**
68
+ * Digest of everything appended so far PLUS `extra`, without appending it.
69
+ *
70
+ * Exists for exactly one caller: the PSK binder after a HelloRetryRequest, which is an HMAC
71
+ * over Transcript-Hash(message_hash(CH1) || HRR || Truncate(CH2)) (RFC 8446 s4.2.11.2). The
72
+ * truncated ClientHello2 must be hashed as a continuation of the real transcript but must
73
+ * never BECOME part of it — the transcript proper gets the full ClientHello2 with its binder,
74
+ * and folding the truncated form in even transiently would leave a window where the two
75
+ * bookkeepings disagree.
76
+ * @param {Uint8Array} extra
77
+ * @returns {Promise<Uint8Array>}
78
+ */
79
+ async hashWith(extra) {
80
+ return new Uint8Array(
81
+ await crypto.subtle.digest(this._hash, concat([...this._chunks, extra], this._len + extra.byteLength)),
82
+ );
83
+ }
84
+
85
+ /**
86
+ * HelloRetryRequest transcript substitution (RFC 8446 s4.4.1): when a ServerHello is an HRR,
87
+ * the transcript restarts as a synthetic handshake message
88
+ *
89
+ * message_hash(0xFE) || uint24 Hash.length || Hash(ClientHello1)
90
+ *
91
+ * so that a stateless server need only remember the hash of the first ClientHello. Call this
92
+ * after ClientHello1 is the only message in the transcript, before appending the HRR itself.
93
+ * @returns {Promise<void>}
94
+ */
95
+ async replaceWithMessageHash() {
96
+ const digest = await this.hash();
97
+ this._chunks = [concat([u8(HANDSHAKE_TYPE.message_hash), u24(digest.byteLength), digest])];
98
+ this._len = this._chunks[0].byteLength;
99
+ this._cached = null;
100
+ }
101
+ }
@@ -0,0 +1,224 @@
1
+ // TLS wire primitives: length-prefixed vectors, in both directions.
2
+ //
3
+ // TLS is almost entirely nested length-prefixed vectors, and nearly every historical parsing CVE
4
+ // in TLS stacks comes from trusting one of those lengths. So the reader here has exactly one
5
+ // policy: a length that does not fit inside its parent is an error, and a structure with bytes
6
+ // left over when it should be exhausted is an error. Never "read what you can".
7
+
8
+ import { TlsError, codes } from '../errors.js';
9
+ import { concat, u8, u16, u24 } from '../util/bytes.js';
10
+
11
+ /** Sequential reader over a byte range with hard bounds. */
12
+ export class Cursor {
13
+ /**
14
+ * @param {Uint8Array} bytes
15
+ * @param {string} what named in every error so a failure says which structure was malformed
16
+ */
17
+ constructor(bytes, what = 'structure') {
18
+ this.bytes = bytes;
19
+ this.pos = 0;
20
+ this.what = what;
21
+ }
22
+
23
+ get remaining() {
24
+ return this.bytes.byteLength - this.pos;
25
+ }
26
+
27
+ get done() {
28
+ return this.pos >= this.bytes.byteLength;
29
+ }
30
+
31
+ /**
32
+ * @param {number} n
33
+ * @param {string} field
34
+ */
35
+ _need(n, field) {
36
+ if (n < 0 || this.remaining < n) {
37
+ throw new TlsError(
38
+ codes.TLS_HANDSHAKE,
39
+ `${this.what}: needed ${n} bytes for ${field} but only ${this.remaining} remain ` +
40
+ `at offset ${this.pos} of ${this.bytes.byteLength}`,
41
+ { what: this.what, field, needed: n, remaining: this.remaining, offset: this.pos },
42
+ );
43
+ }
44
+ }
45
+
46
+ /**
47
+ * @param {string} [field]
48
+ * @returns {number}
49
+ */
50
+ u8(field = 'uint8') {
51
+ this._need(1, field);
52
+ return this.bytes[this.pos++];
53
+ }
54
+
55
+ /**
56
+ * @param {string} [field]
57
+ * @returns {number}
58
+ */
59
+ u16(field = 'uint16') {
60
+ this._need(2, field);
61
+ const v = (this.bytes[this.pos] << 8) | this.bytes[this.pos + 1];
62
+ this.pos += 2;
63
+ return v;
64
+ }
65
+
66
+ /**
67
+ * @param {string} [field]
68
+ * @returns {number}
69
+ */
70
+ u24(field = 'uint24') {
71
+ this._need(3, field);
72
+ const v =
73
+ (this.bytes[this.pos] << 16) | (this.bytes[this.pos + 1] << 8) | this.bytes[this.pos + 2];
74
+ this.pos += 3;
75
+ return v;
76
+ }
77
+
78
+ /**
79
+ * @param {string} [field]
80
+ * @returns {number}
81
+ */
82
+ u32(field = 'uint32') {
83
+ this._need(4, field);
84
+ const b = this.bytes;
85
+ const v =
86
+ ((b[this.pos] << 24) | (b[this.pos + 1] << 16) | (b[this.pos + 2] << 8) | b[this.pos + 3]) >>>
87
+ 0;
88
+ this.pos += 4;
89
+ return v;
90
+ }
91
+
92
+ /**
93
+ * Fixed-length opaque bytes. Returns a view into the original buffer, never a copy.
94
+ * @param {number} n
95
+ * @param {string} [field]
96
+ * @returns {Uint8Array}
97
+ */
98
+ take(n, field = 'opaque') {
99
+ this._need(n, field);
100
+ const out = this.bytes.subarray(this.pos, this.pos + n);
101
+ this.pos += n;
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * A vector whose length is carried in `lenBytes` (1, 2 or 3) leading octets.
107
+ * @param {1 | 2 | 3} lenBytes
108
+ * @param {string} [field]
109
+ * @returns {Uint8Array}
110
+ */
111
+ vector(lenBytes, field = 'vector') {
112
+ const n = lenBytes === 1 ? this.u8(`${field} length`)
113
+ : lenBytes === 2 ? this.u16(`${field} length`)
114
+ : this.u24(`${field} length`);
115
+ return this.take(n, field);
116
+ }
117
+
118
+ /**
119
+ * Like `vector`, but hands back a Cursor so nested structures inherit the bound.
120
+ * @param {1 | 2 | 3} lenBytes
121
+ * @param {string} [field]
122
+ * @returns {Cursor}
123
+ */
124
+ sub(lenBytes, field = 'vector') {
125
+ return new Cursor(this.vector(lenBytes, field), `${this.what}.${field}`);
126
+ }
127
+
128
+ /**
129
+ * Assert nothing is left. Trailing data inside a length-delimited structure means our idea of
130
+ * the structure and the peer's disagree, which is exactly when to stop rather than guess.
131
+ * @param {string} [field]
132
+ */
133
+ end(field = 'structure') {
134
+ if (!this.done) {
135
+ throw new TlsError(
136
+ codes.TLS_HANDSHAKE,
137
+ `${this.what}: ${this.remaining} trailing bytes after ${field}`,
138
+ { what: this.what, field, trailing: this.remaining },
139
+ );
140
+ }
141
+ }
142
+ }
143
+
144
+ /** Accumulating writer. Kept dumb: correctness of lengths comes from `vector()` below. */
145
+ export class Builder {
146
+ constructor() {
147
+ /** @type {Uint8Array[]} */
148
+ this.parts = [];
149
+ this.length = 0;
150
+ }
151
+
152
+ /**
153
+ * @param {Uint8Array} bytes
154
+ * @returns {this}
155
+ */
156
+ push(bytes) {
157
+ if (bytes.byteLength) {
158
+ this.parts.push(bytes);
159
+ this.length += bytes.byteLength;
160
+ }
161
+ return this;
162
+ }
163
+
164
+ /** @param {number} n @returns {this} */
165
+ u8(n) {
166
+ return this.push(u8(n));
167
+ }
168
+ /** @param {number} n @returns {this} */
169
+ u16(n) {
170
+ return this.push(u16(n));
171
+ }
172
+ /** @param {number} n @returns {this} */
173
+ u24(n) {
174
+ return this.push(u24(n));
175
+ }
176
+
177
+ /**
178
+ * Write `body` prefixed by its length in `lenBytes` octets. Taking the body as bytes rather
179
+ * than back-patching a placeholder means a length can never drift from what follows it.
180
+ * @param {1 | 2 | 3} lenBytes
181
+ * @param {Uint8Array} body
182
+ * @returns {this}
183
+ */
184
+ vector(lenBytes, body) {
185
+ const n = body.byteLength;
186
+ const max = lenBytes === 1 ? 0xff : lenBytes === 2 ? 0xffff : 0xffffff;
187
+ if (n > max) {
188
+ throw new TlsError(
189
+ codes.TLS_HANDSHAKE,
190
+ `vector of ${n} bytes does not fit in a ${lenBytes}-byte length prefix (max ${max})`,
191
+ { length: n, lenBytes, max },
192
+ );
193
+ }
194
+ if (lenBytes === 1) this.u8(n);
195
+ else if (lenBytes === 2) this.u16(n);
196
+ else this.u24(n);
197
+ return this.push(body);
198
+ }
199
+
200
+ /** @returns {Uint8Array} */
201
+ build() {
202
+ return concat(this.parts, this.length);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Convenience: build a length-prefixed vector standalone.
208
+ * @param {1 | 2 | 3} lenBytes
209
+ * @param {Uint8Array} body
210
+ * @returns {Uint8Array}
211
+ */
212
+ export function vector(lenBytes, body) {
213
+ return new Builder().vector(lenBytes, body).build();
214
+ }
215
+
216
+ /**
217
+ * Encode a handshake message: 1-byte type, 3-byte length, body.
218
+ * @param {number} type
219
+ * @param {Uint8Array} body
220
+ * @returns {Uint8Array}
221
+ */
222
+ export function handshakeMessage(type, body) {
223
+ return new Builder().u8(type).vector(3, body).build();
224
+ }