@apibara/protocol 0.4.0-next.0 → 0.4.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/client.d.ts CHANGED
@@ -1,29 +1,119 @@
1
- import { ChannelCredentials, ClientDuplexStream } from '@grpc/grpc-js';
1
+ import { ChannelCredentials, ClientDuplexStream, ClientOptions, StatusObject } from '@grpc/grpc-js';
2
2
  import { v1alpha2 } from './proto';
3
- export { ChannelCredentials } from '@grpc/grpc-js';
4
- export declare type ErrorHandler = (client: StreamClient, err: Error) => void | Promise<void>;
5
- export declare type CloseHandler = (client: StreamClient) => void | Promise<void>;
6
- export declare type DataHandler = (client: StreamClient, data: v1alpha2.IData) => void | Promise<void>;
7
- export declare type InvalidateHandler = (client: StreamClient, invalidate: v1alpha2.IInvalidate) => void | Promise<void>;
8
- export declare type HeartbeatHandler = (client: StreamClient, invalidate: v1alpha2.IHeartbeat) => void | Promise<void>;
3
+ export { ChannelCredentials, StatusObject } from '@grpc/grpc-js';
9
4
  export declare type DataStream = ClientDuplexStream<v1alpha2.IStreamDataRequest, v1alpha2.IStreamDataResponse>;
5
+ export declare type ConfigureArgs = {
6
+ /**
7
+ * Stream filter, encoded.
8
+ */
9
+ filter: Uint8Array;
10
+ /**
11
+ * How much data in a single message.
12
+ */
13
+ batchSize?: number;
14
+ /**
15
+ * Starting cursor. This cursor is stream-specific.
16
+ */
17
+ cursor?: v1alpha2.ICursor | null;
18
+ /**
19
+ * Data finality, e.g. finalized or accepted.
20
+ */
21
+ finality?: v1alpha2.DataFinality | null;
22
+ };
23
+ /**
24
+ * Reconnect callback return value.
25
+ */
26
+ export declare type OnReconnectResult = {
27
+ /**
28
+ * If `true`, reconnects to the stream.
29
+ */
30
+ reconnect: boolean;
31
+ /**
32
+ * Stream configuration used when reconnecting.
33
+ *
34
+ * By default, the client uses the last configuration passed to
35
+ * `configure` and updates the `cursor` with the most recent one.
36
+ */
37
+ args?: ConfigureArgs;
38
+ };
39
+ /**
40
+ * Reconnect callback.
41
+ */
42
+ export declare type OnReconnect = (err: StatusObject, retryCount: number) => Promise<OnReconnectResult> | OnReconnectResult;
43
+ export declare type StreamClientArgs = {
44
+ /**
45
+ * The stream url.
46
+ */
47
+ url: string;
48
+ /**
49
+ * Grpc credentials.
50
+ *
51
+ * Use `ChannelCredentials.createInsecure()` to disable SSL.
52
+ */
53
+ credentials?: ChannelCredentials;
54
+ /**
55
+ * Grpc client options.
56
+ */
57
+ clientOptions?: ClientOptions;
58
+ /**
59
+ * Callback to control reconnection after receiving an error from the stream.
60
+ *
61
+ * By default uses `defaultOnReconnect`, which only reconnects on internal grpc errors.
62
+ */
63
+ onReconnect?: OnReconnect;
64
+ };
65
+ /**
66
+ * A client to configure and stream data.
67
+ */
10
68
  export declare class StreamClient {
11
69
  private readonly inner;
12
70
  private stream?;
13
- private configured;
14
71
  private stream_id;
15
- constructor({ url, credentials }: {
16
- url: string;
17
- credentials?: ChannelCredentials;
18
- });
19
- connect(): this;
72
+ private onReconnect;
73
+ private configuration?;
74
+ /**
75
+ * Create a new `StreamClient`.
76
+ *
77
+ * Notice that the stream is not connected until you start iterating over it.
78
+ * The stream should be used as an _async iterator_.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * import { StreamClient } from '@apibara/protocol'
83
+ *
84
+ * const client = new StreamClient({ url })
85
+ *
86
+ * client.configure({ filter, cursor })
87
+ *
88
+ * for await (const message of client) {
89
+ * // use message
90
+ * }
91
+ * ```
92
+ */
93
+ constructor({ url, credentials, clientOptions, onReconnect }: StreamClientArgs);
94
+ /**
95
+ * Async iterator over messages in the stream.
96
+ */
20
97
  [Symbol.asyncIterator](): AsyncIterator<v1alpha2.IStreamDataResponse>;
21
- configure({ filter, batchSize, cursor, finality, }: {
22
- filter: Uint8Array;
23
- batchSize?: number;
24
- cursor?: v1alpha2.ICursor | null;
25
- finality?: v1alpha2.DataFinality | null;
26
- }): void;
27
- private ensureConnected;
28
- private ensureConfigured;
98
+ /**
99
+ * Configure the stream to return the requested data.
100
+ *
101
+ * The stream can be reconfigured while streaming data, the client will
102
+ * take care of returning only data for the new configuration even if there
103
+ * are old messages in-flight.
104
+ */
105
+ configure(args: ConfigureArgs): void;
106
+ private _configure;
107
+ private connect;
29
108
  }
109
+ /**
110
+ * A `onReconnect` callback that never reconnects.
111
+ */
112
+ export declare function neverReconnect(_err: StatusObject, _retryCount: number): OnReconnectResult;
113
+ /**
114
+ * A `onReconnect` callback that retries to reconnect up to 5 times.
115
+ *
116
+ * If the error is not an internal error, then it will not reconnect.
117
+ * This callback awaits for `1s * retryCount` before returning.
118
+ */
119
+ export declare function defaultOnReconnect(err: StatusObject, retryCount: number): Promise<OnReconnectResult>;
package/dist/client.js CHANGED
@@ -1,64 +1,170 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.StreamClient = exports.ChannelCredentials = void 0;
3
+ exports.defaultOnReconnect = exports.neverReconnect = exports.StreamClient = exports.ChannelCredentials = void 0;
4
4
  const grpc_js_1 = require("@grpc/grpc-js");
5
5
  const proto_1 = require("./proto");
6
6
  const request_1 = require("./request");
7
7
  var grpc_js_2 = require("@grpc/grpc-js");
8
8
  Object.defineProperty(exports, "ChannelCredentials", { enumerable: true, get: function () { return grpc_js_2.ChannelCredentials; } });
9
9
  const StreamService = proto_1.v1alpha2.protoDescriptor.apibara.node.v1alpha2.Stream;
10
+ /**
11
+ * A client to configure and stream data.
12
+ */
10
13
  class StreamClient {
11
- constructor({ url, credentials }) {
14
+ /**
15
+ * Create a new `StreamClient`.
16
+ *
17
+ * Notice that the stream is not connected until you start iterating over it.
18
+ * The stream should be used as an _async iterator_.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { StreamClient } from '@apibara/protocol'
23
+ *
24
+ * const client = new StreamClient({ url })
25
+ *
26
+ * client.configure({ filter, cursor })
27
+ *
28
+ * for await (const message of client) {
29
+ * // use message
30
+ * }
31
+ * ```
32
+ */
33
+ constructor({ url, credentials, clientOptions, onReconnect }) {
12
34
  this.inner = new StreamService(url, credentials ?? grpc_js_1.ChannelCredentials.createSsl(), {
13
35
  'grpc.keepalive_timeout_ms': 3600000,
36
+ ...clientOptions,
14
37
  });
15
- this.configured = false;
16
38
  this.stream_id = 0;
39
+ this.onReconnect = onReconnect ?? defaultOnReconnect;
17
40
  }
18
- connect() {
19
- this.stream = this.inner.streamData();
20
- return this;
21
- }
41
+ /**
42
+ * Async iterator over messages in the stream.
43
+ */
22
44
  async *[Symbol.asyncIterator]() {
23
- this.ensureConnected();
24
- this.ensureConfigured();
25
- if (this.stream) {
26
- for await (const message of this.stream) {
27
- const messageTyped = message;
28
- // only return messages if they are with the most recently configured stream
29
- if (messageTyped.streamId?.toString() == this.stream_id.toString()) {
30
- yield messageTyped;
45
+ if (!this.configuration) {
46
+ throw new Error('StreamClient must be configured');
47
+ }
48
+ // connect if not connected.
49
+ if (!this.stream) {
50
+ this.connect();
51
+ this._configure(this.configuration);
52
+ }
53
+ while (true) {
54
+ let retryCount = 1;
55
+ let cursor = null;
56
+ try {
57
+ // this check is to make ts happy
58
+ if (!this.stream) {
59
+ throw new Error('Stream disconnected unexpectedly');
60
+ }
61
+ for await (const message of this.stream) {
62
+ const messageTyped = message;
63
+ // only return messages if they are with the most recently configured stream
64
+ if (messageTyped.streamId?.toString() == this.stream_id.toString()) {
65
+ // reset retry count on new message
66
+ retryCount = 1;
67
+ // keep cursor updated for use when reconnecting
68
+ if (messageTyped.data) {
69
+ cursor = messageTyped.data.cursor;
70
+ }
71
+ else if (messageTyped.invalidate) {
72
+ cursor = messageTyped.invalidate.cursor;
73
+ }
74
+ yield messageTyped;
75
+ }
76
+ }
77
+ }
78
+ catch (err) {
79
+ const isGrpcError = err.hasOwnProperty('code') &&
80
+ err.hasOwnProperty('details') &&
81
+ err.hasOwnProperty('metadata');
82
+ // non-grpc error, so just bubble it up
83
+ if (!isGrpcError) {
84
+ throw err;
85
+ }
86
+ const { reconnect, args } = await Promise.resolve(this.onReconnect(err, retryCount));
87
+ retryCount += 1;
88
+ if (!reconnect) {
89
+ break;
90
+ }
91
+ this.connect();
92
+ if (args) {
93
+ this._configure(args);
94
+ }
95
+ else {
96
+ // use same configuration specified by user, restarting from the
97
+ // latest ingested batch.
98
+ const configuration = {
99
+ ...this.configuration,
100
+ cursor: cursor ?? this.configuration.cursor,
101
+ };
102
+ this._configure(configuration);
31
103
  }
32
104
  }
33
105
  }
34
106
  }
35
- configure({ filter, batchSize, cursor, finality, }) {
36
- this.ensureConnected();
37
- this.configured = true;
38
- this.stream_id++;
39
- const builder = request_1.StreamDataRequest.create().withStreamId(this.stream_id).withFilter(filter);
40
- if (batchSize) {
41
- builder.withBatchSize(batchSize);
42
- }
43
- if (cursor) {
44
- builder.withStartingCursor(cursor);
45
- }
46
- if (finality) {
47
- builder.withFinality(finality);
48
- }
49
- const request = builder.encode();
50
- this.stream?.write(request);
107
+ /**
108
+ * Configure the stream to return the requested data.
109
+ *
110
+ * The stream can be reconfigured while streaming data, the client will
111
+ * take care of returning only data for the new configuration even if there
112
+ * are old messages in-flight.
113
+ */
114
+ configure(args) {
115
+ this.configuration = args;
116
+ this._configure(args);
51
117
  }
52
- ensureConnected() {
53
- if (!this.stream) {
54
- throw new Error('StreamClient is not connected');
118
+ _configure(args) {
119
+ const { filter, batchSize, cursor, finality } = args;
120
+ this.stream_id++;
121
+ // only send configuration if connected
122
+ if (this.stream) {
123
+ const builder = request_1.StreamDataRequest.create().withStreamId(this.stream_id).withFilter(filter);
124
+ if (batchSize) {
125
+ builder.withBatchSize(batchSize);
126
+ }
127
+ if (cursor) {
128
+ builder.withStartingCursor(cursor);
129
+ }
130
+ if (finality) {
131
+ builder.withFinality(finality);
132
+ }
133
+ const request = builder.encode();
134
+ this.stream?.write(request);
55
135
  }
56
136
  }
57
- ensureConfigured() {
58
- if (!this.configured) {
59
- throw new Error('StreamClient must be configured');
60
- }
137
+ connect() {
138
+ this.stream = this.inner.streamData();
139
+ return this;
61
140
  }
62
141
  }
63
142
  exports.StreamClient = StreamClient;
143
+ /**
144
+ * A `onReconnect` callback that never reconnects.
145
+ */
146
+ function neverReconnect(_err, _retryCount) {
147
+ return {
148
+ reconnect: false,
149
+ };
150
+ }
151
+ exports.neverReconnect = neverReconnect;
152
+ /**
153
+ * A `onReconnect` callback that retries to reconnect up to 5 times.
154
+ *
155
+ * If the error is not an internal error, then it will not reconnect.
156
+ * This callback awaits for `1s * retryCount` before returning.
157
+ */
158
+ async function defaultOnReconnect(err, retryCount) {
159
+ if (err.code != 13) {
160
+ return {
161
+ reconnect: false,
162
+ };
163
+ }
164
+ await new Promise((resolve) => setTimeout(resolve, retryCount * 1000));
165
+ return {
166
+ reconnect: retryCount < 5,
167
+ };
168
+ }
169
+ exports.defaultOnReconnect = defaultOnReconnect;
64
170
  //# sourceMappingURL=client.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":";;;AAAA,2CAAsE;AACtE,mCAAkC;AAClC,uCAA6C;AAE7C,yCAAkD;AAAzC,6GAAA,kBAAkB,OAAA;AAc3B,MAAM,aAAa,GAAG,gBAAQ,CAAC,eAAe,CAAC,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAA;AAO3E,MAAa,YAAY;IAOvB,YAAY,EAAE,GAAG,EAAE,WAAW,EAAqD;QACjF,IAAI,CAAC,KAAK,GAAG,IAAI,aAAa,CAAC,GAAG,EAAE,WAAW,IAAI,4BAAkB,CAAC,SAAS,EAAE,EAAE;YACjF,2BAA2B,EAAE,OAAS;SACvC,CAAC,CAAA;QACF,IAAI,CAAC,UAAU,GAAG,KAAK,CAAA;QACvB,IAAI,CAAC,SAAS,GAAG,CAAC,CAAA;IACpB,CAAC;IAED,OAAO;QACL,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,EAAE,CAAA;QACrC,OAAO,IAAI,CAAA;IACb,CAAC;IAED,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC;QAC3B,IAAI,CAAC,eAAe,EAAE,CAAA;QACtB,IAAI,CAAC,gBAAgB,EAAE,CAAA;QAEvB,IAAI,IAAI,CAAC,MAAM,EAAE;YACf,IAAI,KAAK,EAAE,MAAM,OAAO,IAAI,IAAI,CAAC,MAAM,EAAE;gBACvC,MAAM,YAAY,GAAG,OAAuC,CAAA;gBAC5D,4EAA4E;gBAC5E,IAAI,YAAY,CAAC,QAAQ,EAAE,QAAQ,EAAE,IAAI,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE;oBAClE,MAAM,YAAY,CAAA;iBACnB;aACF;SACF;IACH,CAAC;IAED,SAAS,CAAC,EACR,MAAM,EACN,SAAS,EACT,MAAM,EACN,QAAQ,GAMT;QACC,IAAI,CAAC,eAAe,EAAE,CAAA;QACtB,IAAI,CAAC,UAAU,GAAG,IAAI,CAAA;QAEtB,IAAI,CAAC,SAAS,EAAE,CAAA;QAEhB,MAAM,OAAO,GAAG,2BAAiB,CAAC,MAAM,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAA;QAE1F,IAAI,SAAS,EAAE;YACb,OAAO,CAAC,aAAa,CAAC,SAAS,CAAC,CAAA;SACjC;QACD,IAAI,MAAM,EAAE;YACV,OAAO,CAAC,kBAAkB,CAAC,MAAM,CAAC,CAAA;SACnC;QACD,IAAI,QAAQ,EAAE;YACZ,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAA;SAC/B;QAED,MAAM,OAAO,GAAG,OAAO,CAAC,MAAM,EAAE,CAAA;QAChC,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,OAAO,CAAC,CAAA;IAC7B,CAAC;IAEO,eAAe;QACrB,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE;YAChB,MAAM,IAAI,KAAK,CAAC,+BAA+B,CAAC,CAAA;SACjD;IACH,CAAC;IAEO,gBAAgB;QACtB,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE;YACpB,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAA;SACnD;IACH,CAAC;CACF;AA9ED,oCA8EC"}
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ import Long from 'long';
2
+ import { ICursor } from './proto/v1alpha2';
3
+ export declare const Cursor: {
4
+ /**
5
+ * Creates a new cursor with only the `orderKey` set.
6
+ *
7
+ * Notice that this cursor does not uniquely identify a message in the stream
8
+ * and may result in missing information.
9
+ */
10
+ createWithOrderKey: (order: string | number | Long) => ICursor;
11
+ /**
12
+ * Creates a new cursor with both order and unique keys.
13
+ *
14
+ * This cursor uniquely identifies a message in the stream, even if it has
15
+ * been invalidated.
16
+ */
17
+ create: (order: string | number | Long, unique: Uint8Array) => ICursor;
18
+ /**
19
+ * Returns the cursor string representation.
20
+ */
21
+ toString: (cursor?: ICursor | null) => string | undefined;
22
+ };
package/dist/cursor.js ADDED
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Cursor = void 0;
7
+ const long_1 = __importDefault(require("long"));
8
+ exports.Cursor = {
9
+ /**
10
+ * Creates a new cursor with only the `orderKey` set.
11
+ *
12
+ * Notice that this cursor does not uniquely identify a message in the stream
13
+ * and may result in missing information.
14
+ */
15
+ createWithOrderKey: (order) => {
16
+ return {
17
+ orderKey: long_1.default.fromValue(order),
18
+ uniqueKey: new Uint8Array(),
19
+ };
20
+ },
21
+ /**
22
+ * Creates a new cursor with both order and unique keys.
23
+ *
24
+ * This cursor uniquely identifies a message in the stream, even if it has
25
+ * been invalidated.
26
+ */
27
+ create: (order, unique) => {
28
+ return {
29
+ orderKey: long_1.default.fromValue(order),
30
+ uniqueKey: unique,
31
+ };
32
+ },
33
+ /**
34
+ * Returns the cursor string representation.
35
+ */
36
+ toString: (cursor) => {
37
+ if (!cursor)
38
+ return;
39
+ let hash = Buffer.from(cursor.uniqueKey).toString('hex');
40
+ return `${cursor.orderKey.toString()}/0x${hash}`;
41
+ },
42
+ };
43
+ //# sourceMappingURL=cursor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cursor.js","sourceRoot":"","sources":["../src/cursor.ts"],"names":[],"mappings":";;;;;;AAAA,gDAAuB;AAGV,QAAA,MAAM,GAAG;IACpB;;;;;OAKG;IACH,kBAAkB,EAAE,CAAC,KAA6B,EAAW,EAAE;QAC7D,OAAO;YACL,QAAQ,EAAE,cAAI,CAAC,SAAS,CAAC,KAAK,CAAC;YAC/B,SAAS,EAAE,IAAI,UAAU,EAAE;SAC5B,CAAA;IACH,CAAC;IAED;;;;;OAKG;IACH,MAAM,EAAE,CAAC,KAA6B,EAAE,MAAkB,EAAW,EAAE;QACrE,OAAO;YACL,QAAQ,EAAE,cAAI,CAAC,SAAS,CAAC,KAAK,CAAC;YAC/B,SAAS,EAAE,MAAM;SAClB,CAAA;IACH,CAAC;IAED;;OAEG;IACH,QAAQ,EAAE,CAAC,MAAuB,EAAsB,EAAE;QACxD,IAAI,CAAC,MAAM;YAAE,OAAM;QACnB,IAAI,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAA;QACxD,OAAO,GAAG,MAAM,CAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,CAAA;IAClD,CAAC;CACF,CAAA"}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './proto';
2
2
  export * from './client';
3
3
  export * from './request';
4
+ export * from './cursor';
package/dist/index.js CHANGED
@@ -17,4 +17,5 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./proto"), exports);
18
18
  __exportStar(require("./client"), exports);
19
19
  __exportStar(require("./request"), exports);
20
+ __exportStar(require("./cursor"), exports);
20
21
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAuB;AACvB,2CAAwB;AACxB,4CAAyB"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAuB;AACvB,2CAAwB;AACxB,4CAAyB;AACzB,2CAAwB"}
@@ -5,9 +5,11 @@ export interface Data {
5
5
  'endCursor'?: (_apibara_node_v1alpha2_Cursor | null);
6
6
  'finality'?: (_apibara_node_v1alpha2_DataFinality | keyof typeof _apibara_node_v1alpha2_DataFinality);
7
7
  'data'?: (Buffer | Uint8Array | string)[];
8
+ 'cursor'?: (_apibara_node_v1alpha2_Cursor | null);
8
9
  }
9
10
  export interface Data__Output {
10
11
  'endCursor': (_apibara_node_v1alpha2_Cursor__Output | null);
11
12
  'finality': (keyof typeof _apibara_node_v1alpha2_DataFinality);
12
13
  'data': (Uint8Array)[];
14
+ 'cursor': (_apibara_node_v1alpha2_Cursor__Output | null);
13
15
  }
@@ -4,15 +4,15 @@ import type { Heartbeat as _apibara_node_v1alpha2_Heartbeat, Heartbeat__Output a
4
4
  import type { Long } from '@grpc/proto-loader';
5
5
  export interface StreamDataResponse {
6
6
  'streamId'?: (number | string | Long);
7
- 'Invalidate'?: (_apibara_node_v1alpha2_Invalidate | null);
7
+ 'invalidate'?: (_apibara_node_v1alpha2_Invalidate | null);
8
8
  'data'?: (_apibara_node_v1alpha2_Data | null);
9
9
  'heartbeat'?: (_apibara_node_v1alpha2_Heartbeat | null);
10
- 'message'?: "Invalidate" | "data" | "heartbeat";
10
+ 'message'?: "invalidate" | "data" | "heartbeat";
11
11
  }
12
12
  export interface StreamDataResponse__Output {
13
13
  'streamId': (Long);
14
- 'Invalidate'?: (_apibara_node_v1alpha2_Invalidate__Output | null);
14
+ 'invalidate'?: (_apibara_node_v1alpha2_Invalidate__Output | null);
15
15
  'data'?: (_apibara_node_v1alpha2_Data__Output | null);
16
16
  'heartbeat'?: (_apibara_node_v1alpha2_Heartbeat__Output | null);
17
- 'message': "Invalidate" | "data" | "heartbeat";
17
+ 'message': "invalidate" | "data" | "heartbeat";
18
18
  }
@@ -29,7 +29,7 @@ message StreamDataResponse {
29
29
  // The stream id.
30
30
  uint64 stream_id = 1;
31
31
  oneof message {
32
- Invalidate Invalidate = 2;
32
+ Invalidate invalidate = 2;
33
33
  Data data = 3;
34
34
  Heartbeat heartbeat = 4;
35
35
  }
@@ -68,6 +68,8 @@ message Data {
68
68
  DataFinality finality = 2;
69
69
  // The stream data.
70
70
  repeated bytes data = 3;
71
+ // Cursor used to produced the batch.
72
+ Cursor cursor = 4;
71
73
  }
72
74
 
73
75
  // Sent to clients to check if stream is still connected.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apibara/protocol",
3
- "version": "0.4.0-next.0",
3
+ "version": "0.4.0",
4
4
  "source": "src/index.ts",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/client.ts CHANGED
@@ -1,20 +1,8 @@
1
- import { ChannelCredentials, ClientDuplexStream } from '@grpc/grpc-js'
1
+ import { ChannelCredentials, ClientDuplexStream, ClientOptions, StatusObject } from '@grpc/grpc-js'
2
2
  import { v1alpha2 } from './proto'
3
3
  import { StreamDataRequest } from './request'
4
4
 
5
- export { ChannelCredentials } from '@grpc/grpc-js'
6
-
7
- export type ErrorHandler = (client: StreamClient, err: Error) => void | Promise<void>
8
- export type CloseHandler = (client: StreamClient) => void | Promise<void>
9
- export type DataHandler = (client: StreamClient, data: v1alpha2.IData) => void | Promise<void>
10
- export type InvalidateHandler = (
11
- client: StreamClient,
12
- invalidate: v1alpha2.IInvalidate
13
- ) => void | Promise<void>
14
- export type HeartbeatHandler = (
15
- client: StreamClient,
16
- invalidate: v1alpha2.IHeartbeat
17
- ) => void | Promise<void>
5
+ export { ChannelCredentials, StatusObject } from '@grpc/grpc-js'
18
6
 
19
7
  const StreamService = v1alpha2.protoDescriptor.apibara.node.v1alpha2.Stream
20
8
 
@@ -23,82 +11,261 @@ export type DataStream = ClientDuplexStream<
23
11
  v1alpha2.IStreamDataResponse
24
12
  >
25
13
 
14
+ export type ConfigureArgs = {
15
+ /**
16
+ * Stream filter, encoded.
17
+ */
18
+ filter: Uint8Array
19
+
20
+ /**
21
+ * How much data in a single message.
22
+ */
23
+ batchSize?: number
24
+
25
+ /**
26
+ * Starting cursor. This cursor is stream-specific.
27
+ */
28
+ cursor?: v1alpha2.ICursor | null
29
+
30
+ /**
31
+ * Data finality, e.g. finalized or accepted.
32
+ */
33
+ finality?: v1alpha2.DataFinality | null
34
+ }
35
+
36
+ /**
37
+ * Reconnect callback return value.
38
+ */
39
+ export type OnReconnectResult = {
40
+ /**
41
+ * If `true`, reconnects to the stream.
42
+ */
43
+ reconnect: boolean
44
+
45
+ /**
46
+ * Stream configuration used when reconnecting.
47
+ *
48
+ * By default, the client uses the last configuration passed to
49
+ * `configure` and updates the `cursor` with the most recent one.
50
+ */
51
+ args?: ConfigureArgs
52
+ }
53
+
54
+ /**
55
+ * Reconnect callback.
56
+ */
57
+ export type OnReconnect = (
58
+ err: StatusObject,
59
+ retryCount: number
60
+ ) => Promise<OnReconnectResult> | OnReconnectResult
61
+
62
+ export type StreamClientArgs = {
63
+ /**
64
+ * The stream url.
65
+ */
66
+ url: string
67
+
68
+ /**
69
+ * Grpc credentials.
70
+ *
71
+ * Use `ChannelCredentials.createInsecure()` to disable SSL.
72
+ */
73
+ credentials?: ChannelCredentials
74
+
75
+ /**
76
+ * Grpc client options.
77
+ */
78
+ clientOptions?: ClientOptions
79
+ /**
80
+ * Callback to control reconnection after receiving an error from the stream.
81
+ *
82
+ * By default uses `defaultOnReconnect`, which only reconnects on internal grpc errors.
83
+ */
84
+ onReconnect?: OnReconnect
85
+ }
86
+
87
+ /**
88
+ * A client to configure and stream data.
89
+ */
26
90
  export class StreamClient {
27
91
  private readonly inner: v1alpha2.StreamClient
28
92
 
29
93
  private stream?: DataStream
30
- private configured: boolean
31
94
  private stream_id: number
95
+ private onReconnect: OnReconnect
96
+ private configuration?: ConfigureArgs
32
97
 
33
- constructor({ url, credentials }: { url: string; credentials?: ChannelCredentials }) {
98
+ /**
99
+ * Create a new `StreamClient`.
100
+ *
101
+ * Notice that the stream is not connected until you start iterating over it.
102
+ * The stream should be used as an _async iterator_.
103
+ *
104
+ * @example
105
+ * ```ts
106
+ * import { StreamClient } from '@apibara/protocol'
107
+ *
108
+ * const client = new StreamClient({ url })
109
+ *
110
+ * client.configure({ filter, cursor })
111
+ *
112
+ * for await (const message of client) {
113
+ * // use message
114
+ * }
115
+ * ```
116
+ */
117
+ constructor({ url, credentials, clientOptions, onReconnect }: StreamClientArgs) {
34
118
  this.inner = new StreamService(url, credentials ?? ChannelCredentials.createSsl(), {
35
119
  'grpc.keepalive_timeout_ms': 3_600_000,
120
+ ...clientOptions,
36
121
  })
37
- this.configured = false
38
122
  this.stream_id = 0
123
+ this.onReconnect = onReconnect ?? defaultOnReconnect
39
124
  }
40
125
 
41
- connect() {
42
- this.stream = this.inner.streamData()
43
- return this
44
- }
45
-
126
+ /**
127
+ * Async iterator over messages in the stream.
128
+ */
46
129
  async *[Symbol.asyncIterator](): AsyncIterator<v1alpha2.IStreamDataResponse> {
47
- this.ensureConnected()
48
- this.ensureConfigured()
130
+ if (!this.configuration) {
131
+ throw new Error('StreamClient must be configured')
132
+ }
49
133
 
50
- if (this.stream) {
51
- for await (const message of this.stream) {
52
- const messageTyped = message as v1alpha2.IStreamDataResponse
53
- // only return messages if they are with the most recently configured stream
54
- if (messageTyped.streamId?.toString() == this.stream_id.toString()) {
55
- yield messageTyped
134
+ // connect if not connected.
135
+ if (!this.stream) {
136
+ this.connect()
137
+ this._configure(this.configuration)
138
+ }
139
+
140
+ while (true) {
141
+ let retryCount = 1
142
+ let cursor = null
143
+ try {
144
+ // this check is to make ts happy
145
+ if (!this.stream) {
146
+ throw new Error('Stream disconnected unexpectedly')
147
+ }
148
+
149
+ for await (const message of this.stream) {
150
+ const messageTyped = message as v1alpha2.IStreamDataResponse
151
+
152
+ // only return messages if they are with the most recently configured stream
153
+ if (messageTyped.streamId?.toString() == this.stream_id.toString()) {
154
+ // reset retry count on new message
155
+ retryCount = 1
156
+
157
+ // keep cursor updated for use when reconnecting
158
+ if (messageTyped.data) {
159
+ cursor = messageTyped.data.cursor
160
+ } else if (messageTyped.invalidate) {
161
+ cursor = messageTyped.invalidate.cursor
162
+ }
163
+
164
+ yield messageTyped
165
+ }
166
+ }
167
+ } catch (err: any) {
168
+ const isGrpcError =
169
+ err.hasOwnProperty('code') &&
170
+ err.hasOwnProperty('details') &&
171
+ err.hasOwnProperty('metadata')
172
+
173
+ // non-grpc error, so just bubble it up
174
+ if (!isGrpcError) {
175
+ throw err
176
+ }
177
+
178
+ const { reconnect, args } = await Promise.resolve(this.onReconnect(err, retryCount))
179
+ retryCount += 1
180
+ if (!reconnect) {
181
+ break
182
+ }
183
+
184
+ this.connect()
185
+
186
+ if (args) {
187
+ this._configure(args)
188
+ } else {
189
+ // use same configuration specified by user, restarting from the
190
+ // latest ingested batch.
191
+ const configuration = {
192
+ ...this.configuration,
193
+ cursor: cursor ?? this.configuration.cursor,
194
+ }
195
+ this._configure(configuration)
56
196
  }
57
197
  }
58
198
  }
59
199
  }
60
200
 
61
- configure({
62
- filter,
63
- batchSize,
64
- cursor,
65
- finality,
66
- }: {
67
- filter: Uint8Array
68
- batchSize?: number
69
- cursor?: v1alpha2.ICursor | null
70
- finality?: v1alpha2.DataFinality | null
71
- }) {
72
- this.ensureConnected()
73
- this.configured = true
201
+ /**
202
+ * Configure the stream to return the requested data.
203
+ *
204
+ * The stream can be reconfigured while streaming data, the client will
205
+ * take care of returning only data for the new configuration even if there
206
+ * are old messages in-flight.
207
+ */
208
+ configure(args: ConfigureArgs) {
209
+ this.configuration = args
210
+ this._configure(args)
211
+ }
74
212
 
213
+ private _configure(args: ConfigureArgs) {
214
+ const { filter, batchSize, cursor, finality } = args
75
215
  this.stream_id++
76
216
 
77
- const builder = StreamDataRequest.create().withStreamId(this.stream_id).withFilter(filter)
217
+ // only send configuration if connected
218
+ if (this.stream) {
219
+ const builder = StreamDataRequest.create().withStreamId(this.stream_id).withFilter(filter)
78
220
 
79
- if (batchSize) {
80
- builder.withBatchSize(batchSize)
81
- }
82
- if (cursor) {
83
- builder.withStartingCursor(cursor)
84
- }
85
- if (finality) {
86
- builder.withFinality(finality)
221
+ if (batchSize) {
222
+ builder.withBatchSize(batchSize)
223
+ }
224
+ if (cursor) {
225
+ builder.withStartingCursor(cursor)
226
+ }
227
+ if (finality) {
228
+ builder.withFinality(finality)
229
+ }
230
+
231
+ const request = builder.encode()
232
+ this.stream?.write(request)
87
233
  }
234
+ }
88
235
 
89
- const request = builder.encode()
90
- this.stream?.write(request)
236
+ private connect() {
237
+ this.stream = this.inner.streamData()
238
+ return this
91
239
  }
240
+ }
92
241
 
93
- private ensureConnected() {
94
- if (!this.stream) {
95
- throw new Error('StreamClient is not connected')
96
- }
242
+ /**
243
+ * A `onReconnect` callback that never reconnects.
244
+ */
245
+ export function neverReconnect(_err: StatusObject, _retryCount: number): OnReconnectResult {
246
+ return {
247
+ reconnect: false,
97
248
  }
249
+ }
98
250
 
99
- private ensureConfigured() {
100
- if (!this.configured) {
101
- throw new Error('StreamClient must be configured')
251
+ /**
252
+ * A `onReconnect` callback that retries to reconnect up to 5 times.
253
+ *
254
+ * If the error is not an internal error, then it will not reconnect.
255
+ * This callback awaits for `1s * retryCount` before returning.
256
+ */
257
+ export async function defaultOnReconnect(
258
+ err: StatusObject,
259
+ retryCount: number
260
+ ): Promise<OnReconnectResult> {
261
+ if (err.code != 13) {
262
+ return {
263
+ reconnect: false,
102
264
  }
103
265
  }
266
+
267
+ await new Promise((resolve) => setTimeout(resolve, retryCount * 1000))
268
+ return {
269
+ reconnect: retryCount < 5,
270
+ }
104
271
  }
package/src/cursor.ts ADDED
@@ -0,0 +1,39 @@
1
+ import Long from 'long'
2
+ import { ICursor } from './proto/v1alpha2'
3
+
4
+ export const Cursor = {
5
+ /**
6
+ * Creates a new cursor with only the `orderKey` set.
7
+ *
8
+ * Notice that this cursor does not uniquely identify a message in the stream
9
+ * and may result in missing information.
10
+ */
11
+ createWithOrderKey: (order: string | number | Long): ICursor => {
12
+ return {
13
+ orderKey: Long.fromValue(order),
14
+ uniqueKey: new Uint8Array(),
15
+ }
16
+ },
17
+
18
+ /**
19
+ * Creates a new cursor with both order and unique keys.
20
+ *
21
+ * This cursor uniquely identifies a message in the stream, even if it has
22
+ * been invalidated.
23
+ */
24
+ create: (order: string | number | Long, unique: Uint8Array): ICursor => {
25
+ return {
26
+ orderKey: Long.fromValue(order),
27
+ uniqueKey: unique,
28
+ }
29
+ },
30
+
31
+ /**
32
+ * Returns the cursor string representation.
33
+ */
34
+ toString: (cursor?: ICursor | null): string | undefined => {
35
+ if (!cursor) return
36
+ let hash = Buffer.from(cursor.uniqueKey).toString('hex')
37
+ return `${cursor.orderKey.toString()}/0x${hash}`
38
+ },
39
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './proto'
2
2
  export * from './client'
3
3
  export * from './request'
4
+ export * from './cursor'
@@ -7,10 +7,12 @@ export interface Data {
7
7
  'endCursor'?: (_apibara_node_v1alpha2_Cursor | null);
8
8
  'finality'?: (_apibara_node_v1alpha2_DataFinality | keyof typeof _apibara_node_v1alpha2_DataFinality);
9
9
  'data'?: (Buffer | Uint8Array | string)[];
10
+ 'cursor'?: (_apibara_node_v1alpha2_Cursor | null);
10
11
  }
11
12
 
12
13
  export interface Data__Output {
13
14
  'endCursor': (_apibara_node_v1alpha2_Cursor__Output | null);
14
15
  'finality': (keyof typeof _apibara_node_v1alpha2_DataFinality);
15
16
  'data': (Uint8Array)[];
17
+ 'cursor': (_apibara_node_v1alpha2_Cursor__Output | null);
16
18
  }
@@ -7,16 +7,16 @@ import type { Long } from '@grpc/proto-loader';
7
7
 
8
8
  export interface StreamDataResponse {
9
9
  'streamId'?: (number | string | Long);
10
- 'Invalidate'?: (_apibara_node_v1alpha2_Invalidate | null);
10
+ 'invalidate'?: (_apibara_node_v1alpha2_Invalidate | null);
11
11
  'data'?: (_apibara_node_v1alpha2_Data | null);
12
12
  'heartbeat'?: (_apibara_node_v1alpha2_Heartbeat | null);
13
- 'message'?: "Invalidate"|"data"|"heartbeat";
13
+ 'message'?: "invalidate"|"data"|"heartbeat";
14
14
  }
15
15
 
16
16
  export interface StreamDataResponse__Output {
17
17
  'streamId': (Long);
18
- 'Invalidate'?: (_apibara_node_v1alpha2_Invalidate__Output | null);
18
+ 'invalidate'?: (_apibara_node_v1alpha2_Invalidate__Output | null);
19
19
  'data'?: (_apibara_node_v1alpha2_Data__Output | null);
20
20
  'heartbeat'?: (_apibara_node_v1alpha2_Heartbeat__Output | null);
21
- 'message': "Invalidate"|"data"|"heartbeat";
21
+ 'message': "invalidate"|"data"|"heartbeat";
22
22
  }
@@ -29,7 +29,7 @@ message StreamDataResponse {
29
29
  // The stream id.
30
30
  uint64 stream_id = 1;
31
31
  oneof message {
32
- Invalidate Invalidate = 2;
32
+ Invalidate invalidate = 2;
33
33
  Data data = 3;
34
34
  Heartbeat heartbeat = 4;
35
35
  }
@@ -68,6 +68,8 @@ message Data {
68
68
  DataFinality finality = 2;
69
69
  // The stream data.
70
70
  repeated bytes data = 3;
71
+ // Cursor used to produced the batch.
72
+ Cursor cursor = 4;
71
73
  }
72
74
 
73
75
  // Sent to clients to check if stream is still connected.