@lodestar/reqresp 1.35.0-dev.a70bac5bd3 → 1.35.0-dev.ba92bd8a88

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/lib/ReqResp.d.ts +1 -1
  2. package/lib/ReqResp.d.ts.map +1 -0
  3. package/lib/ReqResp.js +16 -6
  4. package/lib/ReqResp.js.map +1 -1
  5. package/lib/encoders/requestDecode.d.ts.map +1 -0
  6. package/lib/encoders/requestEncode.d.ts.map +1 -0
  7. package/lib/encoders/responseDecode.d.ts +1 -1
  8. package/lib/encoders/responseDecode.d.ts.map +1 -0
  9. package/lib/encoders/responseDecode.js.map +1 -1
  10. package/lib/encoders/responseEncode.d.ts.map +1 -0
  11. package/lib/encodingStrategies/index.d.ts.map +1 -0
  12. package/lib/encodingStrategies/sszSnappy/decode.d.ts.map +1 -0
  13. package/lib/encodingStrategies/sszSnappy/encode.d.ts.map +1 -0
  14. package/lib/encodingStrategies/sszSnappy/errors.d.ts.map +1 -0
  15. package/lib/encodingStrategies/sszSnappy/index.d.ts +1 -1
  16. package/lib/encodingStrategies/sszSnappy/index.d.ts.map +1 -0
  17. package/lib/encodingStrategies/sszSnappy/index.js +1 -1
  18. package/lib/encodingStrategies/sszSnappy/snappyFrames/common.d.ts.map +1 -0
  19. package/lib/encodingStrategies/sszSnappy/snappyFrames/compress.d.ts.map +1 -0
  20. package/lib/encodingStrategies/sszSnappy/snappyFrames/uncompress.d.ts.map +1 -0
  21. package/lib/encodingStrategies/sszSnappy/snappyFrames/uncompress.js +4 -6
  22. package/lib/encodingStrategies/sszSnappy/snappyFrames/uncompress.js.map +1 -1
  23. package/lib/encodingStrategies/sszSnappy/utils.d.ts.map +1 -0
  24. package/lib/index.d.ts +7 -7
  25. package/lib/index.d.ts.map +1 -0
  26. package/lib/index.js +5 -5
  27. package/lib/index.js.map +1 -1
  28. package/lib/interface.d.ts.map +1 -0
  29. package/lib/metrics.d.ts.map +1 -0
  30. package/lib/rate_limiter/ReqRespRateLimiter.d.ts.map +1 -0
  31. package/lib/rate_limiter/ReqRespRateLimiter.js +8 -4
  32. package/lib/rate_limiter/ReqRespRateLimiter.js.map +1 -1
  33. package/lib/rate_limiter/rateLimiterGRCA.d.ts.map +1 -0
  34. package/lib/rate_limiter/rateLimiterGRCA.js +5 -3
  35. package/lib/rate_limiter/rateLimiterGRCA.js.map +1 -1
  36. package/lib/rate_limiter/selfRateLimiter.d.ts.map +1 -0
  37. package/lib/rate_limiter/selfRateLimiter.js +9 -2
  38. package/lib/rate_limiter/selfRateLimiter.js.map +1 -1
  39. package/lib/request/errors.d.ts.map +1 -0
  40. package/lib/request/index.d.ts +1 -1
  41. package/lib/request/index.d.ts.map +1 -0
  42. package/lib/request/index.js +1 -1
  43. package/lib/request/index.js.map +1 -1
  44. package/lib/response/errors.d.ts.map +1 -0
  45. package/lib/response/errors.js +2 -0
  46. package/lib/response/errors.js.map +1 -1
  47. package/lib/response/index.d.ts.map +1 -0
  48. package/lib/response/index.js +1 -1
  49. package/lib/response/index.js.map +1 -1
  50. package/lib/types.d.ts.map +1 -0
  51. package/lib/utils/abortableSource.d.ts.map +1 -0
  52. package/lib/utils/bufferedSource.d.ts.map +1 -0
  53. package/lib/utils/bufferedSource.js +3 -1
  54. package/lib/utils/bufferedSource.js.map +1 -1
  55. package/lib/utils/collectExactOne.d.ts.map +1 -0
  56. package/lib/utils/collectMaxResponse.d.ts.map +1 -0
  57. package/lib/utils/errorMessage.d.ts.map +1 -0
  58. package/lib/utils/index.d.ts.map +1 -0
  59. package/lib/utils/onChunk.d.ts.map +1 -0
  60. package/lib/utils/peerId.d.ts.map +1 -0
  61. package/lib/utils/protocolId.d.ts.map +1 -0
  62. package/package.json +12 -12
  63. package/src/ReqResp.ts +289 -0
  64. package/src/encoders/requestDecode.ts +29 -0
  65. package/src/encoders/requestEncode.ts +18 -0
  66. package/src/encoders/responseDecode.ts +169 -0
  67. package/src/encoders/responseEncode.ts +81 -0
  68. package/src/encodingStrategies/index.ts +46 -0
  69. package/src/encodingStrategies/sszSnappy/decode.ts +111 -0
  70. package/src/encodingStrategies/sszSnappy/encode.ts +24 -0
  71. package/src/encodingStrategies/sszSnappy/errors.ts +31 -0
  72. package/src/encodingStrategies/sszSnappy/index.ts +3 -0
  73. package/src/encodingStrategies/sszSnappy/snappyFrames/common.ts +36 -0
  74. package/src/encodingStrategies/sszSnappy/snappyFrames/compress.ts +25 -0
  75. package/src/encodingStrategies/sszSnappy/snappyFrames/uncompress.ts +114 -0
  76. package/src/encodingStrategies/sszSnappy/utils.ts +7 -0
  77. package/src/index.ts +10 -0
  78. package/src/interface.ts +26 -0
  79. package/src/metrics.ts +95 -0
  80. package/src/rate_limiter/ReqRespRateLimiter.ts +107 -0
  81. package/src/rate_limiter/rateLimiterGRCA.ts +92 -0
  82. package/src/rate_limiter/selfRateLimiter.ts +112 -0
  83. package/src/request/errors.ts +119 -0
  84. package/src/request/index.ts +225 -0
  85. package/src/response/errors.ts +50 -0
  86. package/src/response/index.ts +147 -0
  87. package/src/types.ts +158 -0
  88. package/src/utils/abortableSource.ts +80 -0
  89. package/src/utils/bufferedSource.ts +46 -0
  90. package/src/utils/collectExactOne.ts +15 -0
  91. package/src/utils/collectMaxResponse.ts +19 -0
  92. package/src/utils/errorMessage.ts +51 -0
  93. package/src/utils/index.ts +8 -0
  94. package/src/utils/onChunk.ts +12 -0
  95. package/src/utils/peerId.ts +6 -0
  96. package/src/utils/protocolId.ts +44 -0
package/package.json CHANGED
@@ -11,13 +11,15 @@
11
11
  "bugs": {
12
12
  "url": "https://github.com/ChainSafe/lodestar/issues"
13
13
  },
14
- "version": "1.35.0-dev.a70bac5bd3",
14
+ "version": "1.35.0-dev.ba92bd8a88",
15
15
  "type": "module",
16
16
  "exports": {
17
17
  ".": {
18
+ "bun": "./src/index.ts",
18
19
  "import": "./lib/index.js"
19
20
  },
20
21
  "./utils": {
22
+ "bun": "./src/utils/index.ts",
21
23
  "import": "./lib/utils/index.js"
22
24
  }
23
25
  },
@@ -32,11 +34,9 @@
32
34
  },
33
35
  "types": "./lib/index.d.ts",
34
36
  "files": [
35
- "lib/**/*.d.ts",
36
- "lib/**/*.js",
37
- "lib/**/*.js.map",
38
- "*.d.ts",
39
- "*.js"
37
+ "src",
38
+ "lib",
39
+ "!**/*.tsbuildinfo"
40
40
  ],
41
41
  "scripts": {
42
42
  "clean": "rm -rf lib && rm -f *.tsbuildinfo",
@@ -54,9 +54,9 @@
54
54
  "dependencies": {
55
55
  "@chainsafe/fast-crc32c": "^4.2.0",
56
56
  "@libp2p/interface": "^2.7.0",
57
- "@lodestar/config": "1.35.0-dev.a70bac5bd3",
58
- "@lodestar/params": "1.35.0-dev.a70bac5bd3",
59
- "@lodestar/utils": "1.35.0-dev.a70bac5bd3",
57
+ "@lodestar/config": "1.35.0-dev.ba92bd8a88",
58
+ "@lodestar/params": "1.35.0-dev.ba92bd8a88",
59
+ "@lodestar/utils": "1.35.0-dev.ba92bd8a88",
60
60
  "it-all": "^3.0.4",
61
61
  "it-pipe": "^3.0.1",
62
62
  "snappy": "^7.2.2",
@@ -65,8 +65,8 @@
65
65
  "uint8arraylist": "^2.4.7"
66
66
  },
67
67
  "devDependencies": {
68
- "@lodestar/logger": "1.35.0-dev.a70bac5bd3",
69
- "@lodestar/types": "1.35.0-dev.a70bac5bd3",
68
+ "@lodestar/logger": "1.35.0-dev.ba92bd8a88",
69
+ "@lodestar/types": "1.35.0-dev.ba92bd8a88",
70
70
  "libp2p": "2.9.0"
71
71
  },
72
72
  "peerDependencies": {
@@ -80,5 +80,5 @@
80
80
  "reqresp",
81
81
  "blockchain"
82
82
  ],
83
- "gitHead": "ed950d79939c776cb95654954528160b4afa7a2e"
83
+ "gitHead": "1b20674959d28aa02d63fea5358fd3332de8e88f"
84
84
  }
package/src/ReqResp.ts ADDED
@@ -0,0 +1,289 @@
1
+ import {setMaxListeners} from "node:events";
2
+ import {Connection, PeerId, Stream} from "@libp2p/interface";
3
+ import type {Libp2p} from "libp2p";
4
+ import {Logger, MetricsRegisterExtra} from "@lodestar/utils";
5
+ import {Metrics, getMetrics} from "./metrics.js";
6
+ import {ReqRespRateLimiter} from "./rate_limiter/ReqRespRateLimiter.js";
7
+ import {SelfRateLimiter} from "./rate_limiter/selfRateLimiter.js";
8
+ import {RequestError, RequestErrorCode, SendRequestOpts, sendRequest} from "./request/index.js";
9
+ import {handleRequest} from "./response/index.js";
10
+ import {
11
+ DialOnlyProtocol,
12
+ Encoding,
13
+ MixedProtocol,
14
+ Protocol,
15
+ ProtocolDescriptor,
16
+ ReqRespRateLimiterOpts,
17
+ ResponseIncoming,
18
+ } from "./types.js";
19
+ import {formatProtocolID} from "./utils/protocolId.js";
20
+
21
+ type ProtocolID = string;
22
+
23
+ export const DEFAULT_PROTOCOL_PREFIX = "/eth2/beacon_chain/req";
24
+
25
+ export interface ReqRespProtocolModules {
26
+ libp2p: Libp2p;
27
+ logger: Logger;
28
+ metricsRegister: MetricsRegisterExtra | null;
29
+ }
30
+
31
+ export interface ReqRespOpts extends SendRequestOpts, ReqRespRateLimiterOpts {
32
+ /** Custom prefix for `/ProtocolPrefix/MessageName/SchemaVersion/Encoding` */
33
+ protocolPrefix?: string;
34
+ getPeerLogMetadata?: (peerId: string) => string;
35
+ }
36
+
37
+ /**
38
+ * Implementation of Ethereum Consensus p2p Req/Resp domain.
39
+ * For the spec that this code is based on, see:
40
+ * https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/p2p-interface.md#the-reqresp-domain
41
+ * https://github.com/ethereum/consensus-specs/blob/dev/specs/altair/light-client/p2p-interface.md#the-reqresp-domain
42
+ */
43
+ export class ReqResp {
44
+ // protected to be usable by extending class
45
+ protected readonly libp2p: Libp2p;
46
+ protected readonly logger: Logger;
47
+ protected readonly metrics: Metrics | null;
48
+
49
+ // rate limit requests coming to this node to not be used by extending class
50
+ private readonly rateLimiter: ReqRespRateLimiter;
51
+ // rate limit requests sending to other peers
52
+ private readonly selfRateLimiter: SelfRateLimiter;
53
+
54
+ private controller = new AbortController();
55
+ /** Tracks request and responses in a sequential counter */
56
+ private reqCount = 0;
57
+ private readonly protocolPrefix: string;
58
+
59
+ /** `${protocolPrefix}/${method}/${version}/${encoding}` */
60
+ private readonly registeredProtocols = new Map<ProtocolID, MixedProtocol>();
61
+ private readonly dialOnlyProtocols = new Map<ProtocolID, boolean>();
62
+
63
+ constructor(
64
+ modules: ReqRespProtocolModules,
65
+ private readonly opts: ReqRespOpts = {}
66
+ ) {
67
+ this.libp2p = modules.libp2p;
68
+ this.logger = modules.logger;
69
+ this.metrics = modules.metricsRegister ? getMetrics(modules.metricsRegister) : null;
70
+ this.protocolPrefix = opts.protocolPrefix ?? DEFAULT_PROTOCOL_PREFIX;
71
+ this.rateLimiter = new ReqRespRateLimiter(opts);
72
+ this.selfRateLimiter = new SelfRateLimiter();
73
+
74
+ this.metrics?.selfRateLimiterPeerCount.addCollect(() => {
75
+ this.metrics?.selfRateLimiterPeerCount.set(this.selfRateLimiter.getPeerCount());
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Register protocol which will be used only to dial to other peers
81
+ * The libp2p instance will not handle that protocol
82
+ *
83
+ * Made it explicit method to avoid any developer mistake
84
+ */
85
+ registerDialOnlyProtocol(protocol: DialOnlyProtocol): void {
86
+ const protocolID = this.formatProtocolID(protocol);
87
+
88
+ this.registeredProtocols.set(protocolID, protocol);
89
+ this.dialOnlyProtocols.set(protocolID, true);
90
+ }
91
+
92
+ /**
93
+ * Register protocol as supported and to libp2p.
94
+ * async because libp2p registrar persists the new protocol list in the peer-store.
95
+ * Overrides handler and rate limits in case protocol is already registered.
96
+ * Can be called at any time, no concept of started / stopped
97
+ */
98
+ async registerProtocol(protocol: Protocol): Promise<void> {
99
+ const protocolID = this.formatProtocolID(protocol);
100
+ const {handler: _handler, inboundRateLimits, ...rest} = protocol;
101
+
102
+ this.registerDialOnlyProtocol(rest);
103
+ this.dialOnlyProtocols.set(protocolID, false);
104
+
105
+ if (inboundRateLimits) {
106
+ this.rateLimiter.setRateLimits(protocolID, inboundRateLimits);
107
+ }
108
+
109
+ return this.libp2p.handle(protocolID, this.getRequestHandler(protocol, protocolID), {force: true});
110
+ }
111
+
112
+ /**
113
+ * Remove protocol as supported and from libp2p.
114
+ * async because libp2p registrar persists the new protocol list in the peer-store.
115
+ * Does NOT throw if the protocolID is unknown.
116
+ * Can be called at any time, no concept of started / stopped
117
+ */
118
+ async unregisterProtocol(protocolID: ProtocolID): Promise<void> {
119
+ this.registeredProtocols.delete(protocolID);
120
+
121
+ return this.libp2p.unhandle(protocolID);
122
+ }
123
+
124
+ /**
125
+ * Remove all registered protocols from libp2p
126
+ */
127
+ async unregisterAllProtocols(): Promise<void> {
128
+ for (const protocolID of this.registeredProtocols.keys()) {
129
+ await this.unregisterProtocol(protocolID);
130
+ }
131
+ }
132
+
133
+ getRegisteredProtocols(): ProtocolID[] {
134
+ return Array.from(this.registeredProtocols.values()).map((protocol) => this.formatProtocolID(protocol));
135
+ }
136
+
137
+ async start(): Promise<void> {
138
+ this.controller = new AbortController();
139
+ this.rateLimiter.start();
140
+ this.selfRateLimiter.start();
141
+ // We set infinity to prevent MaxListenersExceededWarning which get logged when listeners > 10
142
+ // Since it is perfectly fine to have listeners > 10
143
+ setMaxListeners(Infinity, this.controller.signal);
144
+ }
145
+
146
+ async stop(): Promise<void> {
147
+ this.rateLimiter.stop();
148
+ this.selfRateLimiter.stop();
149
+ this.controller.abort();
150
+ }
151
+
152
+ // Helper to reduce code duplication
153
+ async *sendRequest(
154
+ peerId: PeerId,
155
+ method: string,
156
+ versions: number[],
157
+ encoding: Encoding,
158
+ body: Uint8Array
159
+ ): AsyncIterable<ResponseIncoming> {
160
+ const peerIdStr = peerId.toString();
161
+ const peerClient = this.opts.getPeerLogMetadata?.(peerIdStr);
162
+ this.metrics?.outgoingRequests.inc({method});
163
+ const timer = this.metrics?.outgoingRequestRoundtripTime.startTimer({method});
164
+
165
+ const protocols: (MixedProtocol | DialOnlyProtocol)[] = [];
166
+ const protocolIDs: string[] = [];
167
+ // don't increase this.reqCount until we know request will be sent
168
+ const requestId = this.reqCount + 1;
169
+
170
+ for (const version of versions) {
171
+ const protocolID = this.formatProtocolID({method, version, encoding});
172
+ const protocol = this.registeredProtocols.get(protocolID);
173
+ if (!protocol) {
174
+ throw Error(`Request to send to protocol ${protocolID} but it has not been declared`);
175
+ }
176
+
177
+ if (!this.selfRateLimiter.allows(peerIdStr, protocolID, requestId)) {
178
+ // we technically don't send request in this case but would be nice just to track this in the same `outgoingErrorReasons` metric
179
+ this.metrics?.outgoingErrorReasons.inc({reason: RequestErrorCode.REQUEST_SELF_RATE_LIMITED});
180
+ throw new RequestError({code: RequestErrorCode.REQUEST_SELF_RATE_LIMITED});
181
+ // don't call this.onOutgoingRequestError() to penalize peer
182
+ }
183
+
184
+ protocols.push(protocol);
185
+ protocolIDs.push(protocolID);
186
+ }
187
+
188
+ // requestId is now the same to reqCount
189
+ this.reqCount++;
190
+
191
+ try {
192
+ yield* sendRequest(
193
+ {logger: this.logger, libp2p: this.libp2p, metrics: this.metrics, peerClient},
194
+ peerId,
195
+ protocols,
196
+ protocolIDs,
197
+ body,
198
+ this.controller.signal,
199
+ this.opts,
200
+ requestId
201
+ );
202
+ } catch (e) {
203
+ this.metrics?.outgoingErrors.inc({method});
204
+
205
+ if (e instanceof RequestError) {
206
+ if (e.type.code === RequestErrorCode.DIAL_ERROR || e.type.code === RequestErrorCode.DIAL_TIMEOUT) {
207
+ this.metrics?.dialErrors.inc();
208
+ }
209
+ this.metrics?.outgoingErrorReasons.inc({reason: e.type.code});
210
+
211
+ this.onOutgoingRequestError(peerId, method, e);
212
+ }
213
+
214
+ throw e;
215
+ } finally {
216
+ for (const protocolID of protocolIDs) {
217
+ this.selfRateLimiter.requestCompleted(peerIdStr, protocolID, requestId);
218
+ }
219
+ timer?.();
220
+ }
221
+ }
222
+
223
+ private getRequestHandler(protocol: MixedProtocol, protocolID: string) {
224
+ return async ({connection, stream}: {connection: Connection; stream: Stream}) => {
225
+ if (this.dialOnlyProtocols.get(protocolID)) {
226
+ throw new Error(`Received request on dial only protocol '${protocolID}'`);
227
+ }
228
+
229
+ const peerId = connection.remotePeer;
230
+ const peerClient = this.opts.getPeerLogMetadata?.(peerId.toString());
231
+ const {method} = protocol;
232
+
233
+ this.metrics?.incomingRequests.inc({method});
234
+ const timer = this.metrics?.incomingRequestHandlerTime.startTimer({method});
235
+
236
+ this.onIncomingRequest?.(peerId, protocol);
237
+
238
+ try {
239
+ await handleRequest({
240
+ logger: this.logger,
241
+ metrics: this.metrics,
242
+ stream,
243
+ peerId,
244
+ protocol: protocol as Protocol,
245
+ protocolID,
246
+ rateLimiter: this.rateLimiter,
247
+ signal: this.controller.signal,
248
+ requestId: this.reqCount++,
249
+ peerClient,
250
+ requestTimeoutMs: this.opts.requestTimeoutMs,
251
+ });
252
+ // TODO: Do success peer scoring here
253
+ } catch (err) {
254
+ this.metrics?.incomingErrors.inc({method});
255
+
256
+ if (err instanceof RequestError) {
257
+ this.onIncomingRequestError(protocol as ProtocolDescriptor, err);
258
+ }
259
+
260
+ // TODO: Do error peer scoring here
261
+ // Must not throw since this is an event handler
262
+ } finally {
263
+ timer?.();
264
+ }
265
+ };
266
+ }
267
+
268
+ protected onIncomingRequest(_peerId: PeerId, _protocol: ProtocolDescriptor): void {
269
+ // Override
270
+ }
271
+
272
+ protected onIncomingRequestError(_protocol: ProtocolDescriptor, _error: RequestError): void {
273
+ // Override
274
+ }
275
+
276
+ protected onOutgoingRequestError(_peerId: PeerId, _method: string, _error: RequestError): void {
277
+ // Override
278
+ }
279
+
280
+ /**
281
+ * ```
282
+ * /ProtocolPrefix/MessageName/SchemaVersion/Encoding
283
+ * ```
284
+ * https://github.com/ethereum/consensus-specs/blob/v1.2.0/specs/phase0/p2p-interface.md#protocol-identification
285
+ */
286
+ protected formatProtocolID(protocol: Pick<MixedProtocol, "method" | "version" | "encoding">): string {
287
+ return formatProtocolID(this.protocolPrefix, protocol.method, protocol.version, protocol.encoding);
288
+ }
289
+ }
@@ -0,0 +1,29 @@
1
+ import type {Sink} from "it-stream-types";
2
+ import {Uint8ArrayList} from "uint8arraylist";
3
+ import {readEncodedPayload} from "../encodingStrategies/index.js";
4
+ import {MixedProtocol} from "../types.js";
5
+ import {BufferedSource} from "../utils/index.js";
6
+
7
+ const EMPTY_DATA = new Uint8Array();
8
+
9
+ /**
10
+ * Consumes a stream source to read a `<request>`
11
+ * ```bnf
12
+ * request ::= <encoding-dependent-header> | <encoded-payload>
13
+ * ```
14
+ */
15
+ export function requestDecode(
16
+ protocol: MixedProtocol
17
+ ): Sink<AsyncIterable<Uint8Array | Uint8ArrayList>, Promise<Uint8Array>> {
18
+ return async function requestDecodeSink(source) {
19
+ const type = protocol.requestSizes;
20
+ if (type === null) {
21
+ // method has no body
22
+ return EMPTY_DATA;
23
+ }
24
+
25
+ // Request has a single payload, so return immediately
26
+ const bufferedSource = new BufferedSource(source as AsyncGenerator<Uint8ArrayList>);
27
+ return readEncodedPayload(bufferedSource, protocol.encoding, type);
28
+ };
29
+ }
@@ -0,0 +1,18 @@
1
+ import {writeEncodedPayload} from "../encodingStrategies/index.js";
2
+ import {MixedProtocol} from "../types.js";
3
+
4
+ /**
5
+ * Yields byte chunks for a `<request>`
6
+ * ```bnf
7
+ * request ::= <encoding-dependent-header> | <encoded-payload>
8
+ * ```
9
+ * Requests may contain no payload (e.g. /eth2/beacon_chain/req/metadata/1/)
10
+ * if so, it would yield no byte chunks
11
+ */
12
+ export async function* requestEncode(protocol: MixedProtocol, requestBody: Uint8Array): AsyncGenerator<Buffer> {
13
+ const type = protocol.requestSizes;
14
+
15
+ if (type && requestBody !== null) {
16
+ yield* writeEncodedPayload(requestBody, protocol.encoding);
17
+ }
18
+ }
@@ -0,0 +1,169 @@
1
+ import {Uint8ArrayList} from "uint8arraylist";
2
+ import {ForkName} from "@lodestar/params";
3
+ import {readEncodedPayload} from "../encodingStrategies/index.js";
4
+ import {RespStatus} from "../interface.js";
5
+ import {ResponseError} from "../response/index.js";
6
+ import {
7
+ CONTEXT_BYTES_FORK_DIGEST_LENGTH,
8
+ ContextBytesFactory,
9
+ ContextBytesType,
10
+ MixedProtocol,
11
+ ResponseIncoming,
12
+ } from "../types.js";
13
+ import {BufferedSource, decodeErrorMessage} from "../utils/index.js";
14
+
15
+ /**
16
+ * Internal helper type to signal stream ended early
17
+ */
18
+ enum StreamStatus {
19
+ Ended = "STREAM_ENDED",
20
+ }
21
+
22
+ /**
23
+ * Consumes a stream source to read a `<response>`
24
+ * ```bnf
25
+ * response ::= <response_chunk>*
26
+ * response_chunk ::= <result> | <context-bytes> | <encoding-dependent-header> | <encoded-payload>
27
+ * result ::= "0" | "1" | "2" | ["128" ... "255"]
28
+ * ```
29
+ */
30
+ export function responseDecode(
31
+ protocol: MixedProtocol,
32
+ cbs: {
33
+ onFirstHeader: () => void;
34
+ onFirstResponseChunk: () => void;
35
+ }
36
+ ): (source: AsyncIterable<Uint8Array | Uint8ArrayList>) => AsyncIterable<ResponseIncoming> {
37
+ return async function* responseDecodeSink(source) {
38
+ const bufferedSource = new BufferedSource(source as AsyncGenerator<Uint8ArrayList>);
39
+
40
+ let readFirstHeader = false;
41
+ let readFirstResponseChunk = false;
42
+
43
+ // Consumers of `responseDecode()` may limit the number of <response_chunk> and break out of the while loop
44
+ while (!bufferedSource.isDone) {
45
+ const status = await readResultHeader(bufferedSource);
46
+
47
+ // Stream is only allowed to end at the start of a <response_chunk> block
48
+ // The happens when source ends before readResultHeader() can fetch 1 byte
49
+ if (status === StreamStatus.Ended) {
50
+ break;
51
+ }
52
+
53
+ if (!readFirstHeader) {
54
+ cbs.onFirstHeader();
55
+ readFirstHeader = true;
56
+ }
57
+
58
+ // For multiple chunks, only the last chunk is allowed to have a non-zero error
59
+ // code (i.e. The chunk stream is terminated once an error occurs
60
+ if (status !== RespStatus.SUCCESS) {
61
+ const errorMessage = await readErrorMessage(bufferedSource);
62
+ throw new ResponseError(status, errorMessage);
63
+ }
64
+
65
+ const forkName = await readContextBytes(protocol.contextBytes, bufferedSource);
66
+ const typeSizes = protocol.responseSizes(forkName);
67
+ const chunkData = await readEncodedPayload(bufferedSource, protocol.encoding, typeSizes);
68
+
69
+ yield {
70
+ data: chunkData,
71
+ fork: forkName,
72
+ protocolVersion: protocol.version,
73
+ };
74
+
75
+ if (!readFirstResponseChunk) {
76
+ cbs.onFirstResponseChunk();
77
+ readFirstResponseChunk = true;
78
+ }
79
+ }
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Consumes a stream source to read a `<result>`
85
+ * ```bnf
86
+ * result ::= "0" | "1" | "2" | ["128" ... "255"]
87
+ * ```
88
+ * `<response_chunk>` starts with a single-byte response code which determines the contents of the response_chunk
89
+ */
90
+ export async function readResultHeader(bufferedSource: BufferedSource): Promise<RespStatus | StreamStatus> {
91
+ for await (const buffer of bufferedSource) {
92
+ const status = buffer.get(0);
93
+ buffer.consume(1);
94
+
95
+ // If first chunk had zero bytes status === null, get next
96
+ if (status !== null) {
97
+ return status;
98
+ }
99
+ }
100
+
101
+ return StreamStatus.Ended;
102
+ }
103
+
104
+ /**
105
+ * Consumes a stream source to read an optional `<error_response>?`
106
+ * ```bnf
107
+ * error_response ::= <result> | <error_message>?
108
+ * result ::= "1" | "2" | ["128" ... "255"]
109
+ * ```
110
+ */
111
+ export async function readErrorMessage(bufferedSource: BufferedSource): Promise<string> {
112
+ // Read at least 256 or wait for the stream to end
113
+ let length: number | undefined;
114
+ for await (const buffer of bufferedSource) {
115
+ // Wait for next chunk with bytes or for the stream to end
116
+ // Note: The entire <error_message> is expected to be in the same chunk
117
+ if (buffer.length >= 256) {
118
+ length = 256;
119
+ break;
120
+ }
121
+ length = buffer.length;
122
+ }
123
+
124
+ // biome-ignore lint/complexity/useLiteralKeys: It is a private attribute
125
+ const bytes = bufferedSource["buffer"].slice(0, length);
126
+
127
+ try {
128
+ return decodeErrorMessage(bytes);
129
+ } catch (_e) {
130
+ // Error message is optional and may not be included in the response stream
131
+ return Buffer.prototype.toString.call(bytes, "hex");
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Consumes a stream source to read a variable length `<context-bytes>` depending on the method.
137
+ * While `<context-bytes>` has a single type of `ForkDigest`, this function only parses the `ForkName`
138
+ * of the `ForkDigest` or defaults to `phase0`
139
+ */
140
+ export async function readContextBytes(
141
+ contextBytes: ContextBytesFactory,
142
+ bufferedSource: BufferedSource
143
+ ): Promise<ForkName> {
144
+ switch (contextBytes.type) {
145
+ case ContextBytesType.Empty:
146
+ return ForkName.phase0;
147
+
148
+ case ContextBytesType.ForkDigest: {
149
+ const forkDigest = await readContextBytesForkDigest(bufferedSource);
150
+ return contextBytes.config.forkDigest2ForkBoundary(forkDigest).fork;
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Consumes a stream source to read `<context-bytes>`, where it's a fixed-width 4 byte
157
+ */
158
+ export async function readContextBytesForkDigest(bufferedSource: BufferedSource): Promise<Uint8Array> {
159
+ for await (const buffer of bufferedSource) {
160
+ if (buffer.length >= CONTEXT_BYTES_FORK_DIGEST_LENGTH) {
161
+ const bytes = buffer.slice(0, CONTEXT_BYTES_FORK_DIGEST_LENGTH);
162
+ buffer.consume(CONTEXT_BYTES_FORK_DIGEST_LENGTH);
163
+ return bytes;
164
+ }
165
+ }
166
+
167
+ // TODO: Use typed error
168
+ throw Error("Source ended while reading context bytes");
169
+ }
@@ -0,0 +1,81 @@
1
+ import {writeEncodedPayload} from "../encodingStrategies/index.js";
2
+ import {RespStatus, RpcResponseStatusError} from "../interface.js";
3
+ import {ContextBytesFactory, ContextBytesType, MixedProtocol, Protocol, ResponseOutgoing} from "../types.js";
4
+ import {encodeErrorMessage} from "../utils/index.js";
5
+
6
+ const SUCCESS_BUFFER = Buffer.from([RespStatus.SUCCESS]);
7
+
8
+ /**
9
+ * Yields byte chunks for a `<response>` with a zero response code `<result>`
10
+ * ```bnf
11
+ * response ::= <response_chunk>*
12
+ * response_chunk ::= <result> | <context-bytes> | <encoding-dependent-header> | <encoded-payload>
13
+ * result ::= "0"
14
+ * ```
15
+ * Note: `response` has zero or more chunks (denoted by `<>*`)
16
+ */
17
+ export function responseEncodeSuccess(
18
+ protocol: Protocol,
19
+ cbs: {onChunk: (chunkIndex: number) => void}
20
+ ): (source: AsyncIterable<ResponseOutgoing>) => AsyncIterable<Buffer> {
21
+ return async function* responseEncodeSuccessTransform(source) {
22
+ let chunkIndex = 0;
23
+
24
+ for await (const chunk of source) {
25
+ // Postfix increment, return 0 as first chunk
26
+ cbs.onChunk(chunkIndex++);
27
+
28
+ // <result>
29
+ yield SUCCESS_BUFFER;
30
+
31
+ // <context-bytes> - from altair
32
+ const contextBytes = getContextBytes(protocol.contextBytes, chunk);
33
+ if (contextBytes) {
34
+ yield contextBytes as Buffer;
35
+ }
36
+
37
+ // <encoding-dependent-header> | <encoded-payload>
38
+ yield* writeEncodedPayload(chunk.data, protocol.encoding);
39
+ }
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Yields byte chunks for a `<response_chunk>` with a non-zero response code `<result>`
45
+ * denoted as `<error_response>`
46
+ * ```bnf
47
+ * error_response ::= <result> | <error_message>?
48
+ * result ::= "1" | "2" | ["128" ... "255"]
49
+ * ```
50
+ * Only the last `<response_chunk>` is allowed to have a non-zero error code, so this
51
+ * fn yields exactly one `<error_response>` and afterwards the stream must be terminated
52
+ */
53
+ export async function* responseEncodeError(
54
+ protocol: Pick<MixedProtocol, "encoding">,
55
+ status: RpcResponseStatusError,
56
+ errorMessage: string
57
+ ): AsyncGenerator<Buffer> {
58
+ // <result>
59
+ yield Buffer.from([status]);
60
+
61
+ // <error_message>? is optional
62
+ if (errorMessage) {
63
+ yield* encodeErrorMessage(errorMessage, protocol.encoding);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Yields byte chunks for a `<context-bytes>`. See `ContextBytesType` for possible types.
69
+ * This item is mandatory but may be empty.
70
+ */
71
+ function getContextBytes(contextBytes: ContextBytesFactory, chunk: ResponseOutgoing): Uint8Array | null {
72
+ switch (contextBytes.type) {
73
+ // Yield nothing
74
+ case ContextBytesType.Empty:
75
+ return null;
76
+
77
+ // Yield a fixed-width 4 byte chunk, set to the `ForkDigest`
78
+ case ContextBytesType.ForkDigest:
79
+ return contextBytes.config.forkBoundary2ForkDigest(chunk.boundary);
80
+ }
81
+ }
@@ -0,0 +1,46 @@
1
+ import {Encoding, TypeSizes} from "../types.js";
2
+ import {BufferedSource} from "../utils/index.js";
3
+ import {readSszSnappyPayload} from "./sszSnappy/decode.js";
4
+ import {writeSszSnappyPayload} from "./sszSnappy/encode.js";
5
+
6
+ // For more info about Ethereum Consensus request/response encoding strategies, see:
7
+ // https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/p2p-interface.md#encoding-strategies
8
+ // Supported encoding strategies:
9
+ // - ssz_snappy
10
+
11
+ /**
12
+ * Consumes a stream source to read encoded header and payload as defined in the spec:
13
+ * ```
14
+ * <encoding-dependent-header> | <encoded-payload>
15
+ * ```
16
+ */
17
+ export async function readEncodedPayload(
18
+ bufferedSource: BufferedSource,
19
+ encoding: Encoding,
20
+ type: TypeSizes
21
+ ): Promise<Uint8Array> {
22
+ switch (encoding) {
23
+ case Encoding.SSZ_SNAPPY:
24
+ return readSszSnappyPayload(bufferedSource, type);
25
+
26
+ default:
27
+ throw Error("Unsupported encoding");
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Yields byte chunks for encoded header and payload as defined in the spec:
33
+ * ```
34
+ * <encoding-dependent-header> | <encoded-payload>
35
+ * ```
36
+ */
37
+ export async function* writeEncodedPayload(chunkData: Uint8Array, encoding: Encoding): AsyncGenerator<Buffer> {
38
+ switch (encoding) {
39
+ case Encoding.SSZ_SNAPPY:
40
+ yield* writeSszSnappyPayload(chunkData);
41
+ break;
42
+
43
+ default:
44
+ throw Error("Unsupported encoding");
45
+ }
46
+ }