kafka-ts 0.0.3-beta → 0.0.3

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 (177) hide show
  1. package/README.md +68 -8
  2. package/dist/api/api-versions.d.ts +9 -0
  3. package/dist/api/api-versions.js +24 -0
  4. package/dist/api/create-topics.d.ts +38 -0
  5. package/dist/api/create-topics.js +53 -0
  6. package/dist/api/delete-topics.d.ts +18 -0
  7. package/dist/api/delete-topics.js +33 -0
  8. package/dist/api/fetch.d.ts +84 -0
  9. package/dist/api/fetch.js +142 -0
  10. package/dist/api/find-coordinator.d.ts +21 -0
  11. package/dist/api/find-coordinator.js +39 -0
  12. package/dist/api/heartbeat.d.ts +11 -0
  13. package/dist/api/heartbeat.js +27 -0
  14. package/dist/api/index.d.ts +576 -0
  15. package/dist/api/index.js +165 -0
  16. package/dist/api/init-producer-id.d.ts +13 -0
  17. package/dist/api/init-producer-id.js +29 -0
  18. package/dist/api/join-group.d.ts +34 -0
  19. package/dist/api/join-group.js +51 -0
  20. package/dist/api/leave-group.d.ts +19 -0
  21. package/dist/api/leave-group.js +39 -0
  22. package/dist/api/list-offsets.d.ts +29 -0
  23. package/dist/api/list-offsets.js +48 -0
  24. package/dist/api/metadata.d.ts +40 -0
  25. package/dist/api/metadata.js +58 -0
  26. package/dist/api/offset-commit.d.ts +28 -0
  27. package/dist/api/offset-commit.js +48 -0
  28. package/dist/api/offset-fetch.d.ts +31 -0
  29. package/dist/api/offset-fetch.js +55 -0
  30. package/dist/api/produce.d.ts +54 -0
  31. package/dist/api/produce.js +126 -0
  32. package/dist/api/sasl-authenticate.d.ts +11 -0
  33. package/dist/api/sasl-authenticate.js +23 -0
  34. package/dist/api/sasl-handshake.d.ts +6 -0
  35. package/dist/api/sasl-handshake.js +19 -0
  36. package/dist/api/sync-group.d.ts +24 -0
  37. package/dist/api/sync-group.js +36 -0
  38. package/dist/auth/index.d.ts +2 -0
  39. package/dist/auth/index.js +8 -0
  40. package/dist/auth/plain.d.ts +5 -0
  41. package/dist/auth/plain.js +12 -0
  42. package/dist/auth/scram.d.ts +9 -0
  43. package/dist/auth/scram.js +40 -0
  44. package/dist/broker.d.ts +30 -0
  45. package/dist/broker.js +55 -0
  46. package/dist/client.d.ts +23 -0
  47. package/dist/client.js +36 -0
  48. package/dist/cluster.d.ts +27 -0
  49. package/dist/cluster.js +70 -0
  50. package/dist/cluster.test.d.ts +1 -0
  51. package/dist/cluster.test.js +343 -0
  52. package/dist/codecs/gzip.d.ts +2 -0
  53. package/dist/codecs/gzip.js +8 -0
  54. package/dist/codecs/index.d.ts +2 -0
  55. package/dist/codecs/index.js +17 -0
  56. package/dist/codecs/none.d.ts +2 -0
  57. package/dist/codecs/none.js +7 -0
  58. package/dist/codecs/types.d.ts +5 -0
  59. package/dist/codecs/types.js +2 -0
  60. package/dist/connection.d.ts +26 -0
  61. package/dist/connection.js +175 -0
  62. package/dist/consumer/consumer-group.d.ts +41 -0
  63. package/dist/consumer/consumer-group.js +215 -0
  64. package/dist/consumer/consumer-metadata.d.ts +7 -0
  65. package/dist/consumer/consumer-metadata.js +14 -0
  66. package/dist/consumer/consumer.d.ts +44 -0
  67. package/dist/consumer/consumer.js +225 -0
  68. package/dist/consumer/fetch-manager.d.ts +33 -0
  69. package/dist/consumer/fetch-manager.js +140 -0
  70. package/dist/consumer/fetcher.d.ts +25 -0
  71. package/dist/consumer/fetcher.js +64 -0
  72. package/dist/consumer/offset-manager.d.ts +22 -0
  73. package/dist/consumer/offset-manager.js +66 -0
  74. package/dist/consumer/processor.d.ts +19 -0
  75. package/dist/consumer/processor.js +59 -0
  76. package/dist/distributors/assignments-to-replicas.d.ts +16 -0
  77. package/dist/distributors/assignments-to-replicas.js +59 -0
  78. package/dist/distributors/assignments-to-replicas.test.d.ts +1 -0
  79. package/dist/distributors/assignments-to-replicas.test.js +40 -0
  80. package/dist/distributors/messages-to-topic-partition-leaders.d.ts +17 -0
  81. package/dist/distributors/messages-to-topic-partition-leaders.js +15 -0
  82. package/dist/distributors/messages-to-topic-partition-leaders.test.d.ts +1 -0
  83. package/dist/distributors/messages-to-topic-partition-leaders.test.js +30 -0
  84. package/dist/distributors/partitioner.d.ts +7 -0
  85. package/dist/distributors/partitioner.js +23 -0
  86. package/dist/index.d.ts +9 -0
  87. package/dist/index.js +26 -0
  88. package/dist/metadata.d.ts +24 -0
  89. package/dist/metadata.js +106 -0
  90. package/dist/producer/producer.d.ts +24 -0
  91. package/dist/producer/producer.js +131 -0
  92. package/dist/types.d.ts +11 -0
  93. package/dist/types.js +2 -0
  94. package/dist/utils/api.d.ts +9 -0
  95. package/dist/utils/api.js +5 -0
  96. package/dist/utils/crypto.d.ts +8 -0
  97. package/dist/utils/crypto.js +18 -0
  98. package/dist/utils/decoder.d.ts +30 -0
  99. package/dist/utils/decoder.js +152 -0
  100. package/dist/utils/delay.d.ts +1 -0
  101. package/dist/utils/delay.js +5 -0
  102. package/dist/utils/encoder.d.ts +28 -0
  103. package/dist/utils/encoder.js +125 -0
  104. package/dist/utils/error.d.ts +11 -0
  105. package/dist/utils/error.js +27 -0
  106. package/dist/utils/logger.d.ts +9 -0
  107. package/dist/utils/logger.js +32 -0
  108. package/dist/utils/memo.d.ts +1 -0
  109. package/dist/utils/memo.js +16 -0
  110. package/dist/utils/murmur2.d.ts +3 -0
  111. package/dist/utils/murmur2.js +40 -0
  112. package/dist/utils/retrier.d.ts +10 -0
  113. package/dist/utils/retrier.js +22 -0
  114. package/dist/utils/tracer.d.ts +5 -0
  115. package/dist/utils/tracer.js +39 -0
  116. package/package.json +11 -2
  117. package/src/__snapshots__/{request-handler.test.ts.snap → cluster.test.ts.snap} +329 -26
  118. package/src/api/fetch.ts +84 -29
  119. package/src/api/index.ts +3 -1
  120. package/src/api/metadata.ts +1 -1
  121. package/src/api/offset-commit.ts +1 -1
  122. package/src/api/offset-fetch.ts +1 -5
  123. package/src/api/produce.ts +15 -18
  124. package/src/auth/index.ts +2 -0
  125. package/src/auth/plain.ts +10 -0
  126. package/src/auth/scram.ts +52 -0
  127. package/src/broker.ts +7 -9
  128. package/src/client.ts +2 -2
  129. package/src/cluster.test.ts +16 -14
  130. package/src/cluster.ts +38 -40
  131. package/src/codecs/gzip.ts +9 -0
  132. package/src/codecs/index.ts +16 -0
  133. package/src/codecs/none.ts +6 -0
  134. package/src/codecs/types.ts +4 -0
  135. package/src/connection.ts +31 -17
  136. package/src/consumer/consumer-group.ts +43 -23
  137. package/src/consumer/consumer.ts +64 -43
  138. package/src/consumer/fetch-manager.ts +43 -53
  139. package/src/consumer/fetcher.ts +20 -13
  140. package/src/consumer/offset-manager.ts +18 -7
  141. package/src/consumer/processor.ts +14 -8
  142. package/src/distributors/assignments-to-replicas.ts +1 -3
  143. package/src/distributors/partitioner.ts +27 -0
  144. package/src/index.ts +7 -2
  145. package/src/metadata.ts +4 -0
  146. package/src/producer/producer.ts +22 -12
  147. package/src/types.ts +3 -3
  148. package/src/utils/api.ts +1 -1
  149. package/src/utils/crypto.ts +15 -0
  150. package/src/utils/decoder.ts +11 -5
  151. package/src/utils/encoder.ts +29 -22
  152. package/src/utils/logger.ts +37 -0
  153. package/src/utils/murmur2.ts +44 -0
  154. package/src/utils/tracer.ts +40 -22
  155. package/.github/workflows/release.yml +0 -17
  156. package/certs/ca.crt +0 -29
  157. package/certs/ca.key +0 -52
  158. package/certs/ca.srl +0 -1
  159. package/certs/kafka.crt +0 -29
  160. package/certs/kafka.csr +0 -26
  161. package/certs/kafka.key +0 -52
  162. package/certs/kafka.keystore.jks +0 -0
  163. package/certs/kafka.truststore.jks +0 -0
  164. package/docker-compose.yml +0 -104
  165. package/examples/package-lock.json +0 -31
  166. package/examples/package.json +0 -14
  167. package/examples/src/client.ts +0 -9
  168. package/examples/src/consumer.ts +0 -18
  169. package/examples/src/create-topic.ts +0 -44
  170. package/examples/src/producer.ts +0 -24
  171. package/examples/src/replicator.ts +0 -25
  172. package/examples/src/utils/delay.ts +0 -1
  173. package/examples/src/utils/json.ts +0 -1
  174. package/examples/tsconfig.json +0 -7
  175. package/log4j.properties +0 -95
  176. package/scripts/generate-certs.sh +0 -24
  177. package/src/utils/debug.ts +0 -9
package/src/api/index.ts CHANGED
@@ -37,7 +37,9 @@ export const API = {
37
37
  SYNC_GROUP,
38
38
  };
39
39
 
40
- export const getApiName = (api: Api<unknown, unknown>) => Object.entries(API).find(([, v]) => v === api)?.[0];
40
+ const apiNameByKey = Object.fromEntries(Object.entries(API).map(([k, v]) => [v.apiKey, k]));
41
+
42
+ export const getApiName = <Request, Response>(api: Api<Request, Response>) => apiNameByKey[api.apiKey];
41
43
 
42
44
  export const API_ERROR = {
43
45
  UNKNOWN_SERVER_ERROR: -1,
@@ -1,7 +1,7 @@
1
1
  import { createApi } from '../utils/api';
2
2
  import { KafkaTSApiError } from '../utils/error';
3
3
 
4
- export type Metadata = ReturnType<(typeof METADATA)['response']>;
4
+ export type Metadata = Awaited<ReturnType<(typeof METADATA)['response']>>;
5
5
 
6
6
  export const METADATA = createApi({
7
7
  apiKey: 3,
@@ -3,7 +3,7 @@ import { KafkaTSApiError } from '../utils/error';
3
3
 
4
4
  export const OFFSET_COMMIT = createApi({
5
5
  apiKey: 8,
6
- apiVersion: 9,
6
+ apiVersion: 8,
7
7
  request: (
8
8
  encoder,
9
9
  data: {
@@ -3,14 +3,12 @@ import { KafkaTSApiError } from '../utils/error';
3
3
 
4
4
  export const OFFSET_FETCH = createApi({
5
5
  apiKey: 9,
6
- apiVersion: 9,
6
+ apiVersion: 8,
7
7
  request: (
8
8
  encoder,
9
9
  data: {
10
10
  groups: {
11
11
  groupId: string;
12
- memberId: string | null;
13
- memberEpoch: number;
14
12
  topics: {
15
13
  name: string;
16
14
  partitionIndexes: number[];
@@ -24,8 +22,6 @@ export const OFFSET_FETCH = createApi({
24
22
  .writeCompactArray(data.groups, (encoder, group) =>
25
23
  encoder
26
24
  .writeCompactString(group.groupId)
27
- .writeCompactString(group.memberId)
28
- .writeInt32(group.memberEpoch)
29
25
  .writeCompactArray(group.topics, (encoder, topic) =>
30
26
  encoder
31
27
  .writeCompactString(topic.name)
@@ -4,7 +4,7 @@ import { KafkaTSApiError } from '../utils/error.js';
4
4
 
5
5
  export const PRODUCE = createApi({
6
6
  apiKey: 0,
7
- apiVersion: 10,
7
+ apiVersion: 9,
8
8
  request: (
9
9
  encoder,
10
10
  data: {
@@ -28,11 +28,11 @@ export const PRODUCE = createApi({
28
28
  attributes: number;
29
29
  timestampDelta: bigint;
30
30
  offsetDelta: number;
31
- key: string | null;
32
- value: string | null;
31
+ key: Buffer | null;
32
+ value: Buffer | null;
33
33
  headers: {
34
- key: string;
35
- value: string;
34
+ key: Buffer;
35
+ value: Buffer;
36
36
  }[];
37
37
  }[];
38
38
  }[];
@@ -61,14 +61,13 @@ export const PRODUCE = createApi({
61
61
  .writeInt8(record.attributes)
62
62
  .writeVarLong(record.timestampDelta)
63
63
  .writeVarInt(record.offsetDelta)
64
- .writeVarIntString(record.key)
65
- .writeVarIntString(record.value)
64
+ .writeVarIntBuffer(record.key)
65
+ .writeVarIntBuffer(record.value)
66
66
  .writeVarIntArray(record.headers, (encoder, header) =>
67
- encoder.writeVarIntString(header.key).writeVarIntString(header.value),
68
- )
69
- .value();
67
+ encoder.writeVarIntBuffer(header.key).writeVarIntBuffer(header.value),
68
+ );
70
69
 
71
- return encoder.writeVarInt(recordBody.length).write(recordBody);
70
+ return encoder.writeVarInt(recordBody.getByteLength()).writeEncoder(recordBody);
72
71
  })
73
72
  .value();
74
73
 
@@ -76,19 +75,17 @@ export const PRODUCE = createApi({
76
75
  .writeInt32(partition.partitionLeaderEpoch)
77
76
  .writeInt8(2) // magic byte
78
77
  .writeUInt32(unsigned(crc32C(batchBody)))
79
- .write(batchBody)
80
- .value();
78
+ .write(batchBody);
81
79
 
82
80
  const batch = new Encoder()
83
81
  .writeInt64(partition.baseOffset)
84
- .writeInt32(batchHeader.length)
85
- .write(batchHeader)
86
- .value();
82
+ .writeInt32(batchHeader.getByteLength())
83
+ .writeEncoder(batchHeader);
87
84
 
88
85
  return encoder
89
86
  .writeInt32(partition.index)
90
- .writeUVarInt(batch.length + 1) // batch size
91
- .write(batch)
87
+ .writeUVarInt(batch.getByteLength() + 1) // batch size
88
+ .writeEncoder(batch)
92
89
  .writeUVarInt(0);
93
90
  })
94
91
  .writeUVarInt(0),
@@ -0,0 +1,2 @@
1
+ export { saslPlain } from './plain';
2
+ export { saslScramSha256, saslScramSha512 } from './scram';
@@ -0,0 +1,10 @@
1
+ import { API } from "../api";
2
+ import { SASLProvider } from "../broker";
3
+
4
+ export const saslPlain = ({ username, password }: { username: string; password: string }): SASLProvider => ({
5
+ mechanism: 'PLAIN',
6
+ authenticate: async ({ sendRequest }) => {
7
+ const authBytes = [null, username, password].join('\u0000');
8
+ await sendRequest(API.SASL_AUTHENTICATE, { authBytes: Buffer.from(authBytes) });
9
+ },
10
+ });
@@ -0,0 +1,52 @@
1
+ import { API } from '../api';
2
+ import { SASLProvider } from '../broker';
3
+ import { base64Decode, base64Encode, generateNonce, hash, hmac, saltPassword, xor } from '../utils/crypto';
4
+ import { KafkaTSError } from '../utils/error';
5
+
6
+ const saslScram =
7
+ ({ mechanism, keyLength, digest }: { mechanism: string; keyLength: number; digest: string }) =>
8
+ ({ username, password }: { username: string; password: string }): SASLProvider => ({
9
+ mechanism,
10
+ authenticate: async ({ sendRequest }) => {
11
+ const nonce = generateNonce();
12
+ const firstMessage = `n=${username},r=${nonce}`;
13
+
14
+ const { authBytes } = await sendRequest(API.SASL_AUTHENTICATE, {
15
+ authBytes: Buffer.from(`n,,${firstMessage}`),
16
+ });
17
+ if (!authBytes) {
18
+ throw new KafkaTSError('No auth response');
19
+ }
20
+
21
+ const response = Object.fromEntries(
22
+ authBytes
23
+ .toString()
24
+ .split(',')
25
+ .map((pair) => pair.split('=')),
26
+ ) as { r: string; s: string; i: string };
27
+
28
+ const rnonce = response.r;
29
+ if (!rnonce.startsWith(nonce)) {
30
+ throw new KafkaTSError('Invalid nonce');
31
+ }
32
+ const iterations = parseInt(response.i);
33
+ const salt = base64Decode(response.s);
34
+
35
+ const saltedPassword = await saltPassword(password, salt, iterations, keyLength, digest);
36
+ const clientKey = hmac(saltedPassword, 'Client Key', digest);
37
+ const clientKeyHash = hash(clientKey, digest);
38
+
39
+ let finalMessage = `c=${base64Encode('n,,')},r=${rnonce}`;
40
+
41
+ const fullMessage = `${firstMessage},${authBytes.toString()},${finalMessage}`;
42
+ const clientSignature = hmac(clientKeyHash, fullMessage, digest);
43
+ const clientProof = base64Encode(xor(clientKey, clientSignature));
44
+
45
+ finalMessage += `,p=${clientProof}`;
46
+
47
+ await sendRequest(API.SASL_AUTHENTICATE, { authBytes: Buffer.from(finalMessage) });
48
+ },
49
+ });
50
+
51
+ export const saslScramSha256 = saslScram({ mechanism: 'SCRAM-SHA-256', keyLength: 32, digest: 'sha256' });
52
+ export const saslScramSha512 = saslScram({ mechanism: 'SCRAM-SHA-512', keyLength: 64, digest: 'sha512' });
package/src/broker.ts CHANGED
@@ -5,12 +5,15 @@ import { Connection, SendRequest } from './connection';
5
5
  import { KafkaTSError } from './utils/error';
6
6
  import { memo } from './utils/memo';
7
7
 
8
- export type SASLOptions = { mechanism: 'PLAIN'; username: string; password: string };
8
+ export type SASLProvider = {
9
+ mechanism: string;
10
+ authenticate: (context: { sendRequest: SendRequest }) => Promise<void>;
11
+ };
9
12
 
10
13
  type BrokerOptions = {
11
14
  clientId: string | null;
12
15
  options: TcpSocketConnectOpts;
13
- sasl: SASLOptions | null;
16
+ sasl: SASLProvider | null;
14
17
  ssl: TLSSocketOptions | null;
15
18
  };
16
19
 
@@ -51,7 +54,7 @@ export class Broker {
51
54
  }
52
55
  const { apiVersion } = apiByKey[apiKey];
53
56
  if (apiVersion < minVersion || apiVersion > maxVersion) {
54
- throw new KafkaTSError(`API ${apiKey} version ${apiVersion} is not supported by the broker`);
57
+ throw new KafkaTSError(`API ${apiKey} version ${apiVersion} is not supported by the broker (minVersion=${minVersion}, maxVersion=${maxVersion})`);
55
58
  }
56
59
  });
57
60
  }
@@ -64,11 +67,6 @@ export class Broker {
64
67
  }
65
68
 
66
69
  private async saslAuthenticate() {
67
- if (this.options.sasl?.mechanism !== 'PLAIN') {
68
- return;
69
- }
70
- const { username, password } = this.options.sasl;
71
- const authBytes = [null, username, password].join('\u0000');
72
- await this.sendRequest(API.SASL_AUTHENTICATE, { authBytes: Buffer.from(authBytes) });
70
+ await this.options.sasl?.authenticate({ sendRequest: this.sendRequest });
73
71
  }
74
72
  }
package/src/client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { TcpSocketConnectOpts } from 'net';
2
2
  import { TLSSocketOptions } from 'tls';
3
- import { SASLOptions } from './broker';
3
+ import { SASLProvider } from './broker';
4
4
  import { Cluster } from './cluster';
5
5
  import { Consumer, ConsumerOptions } from './consumer/consumer';
6
6
  import { Producer, ProducerOptions } from './producer/producer';
@@ -8,7 +8,7 @@ import { Producer, ProducerOptions } from './producer/producer';
8
8
  type ClientOptions = {
9
9
  clientId?: string | null;
10
10
  bootstrapServers: TcpSocketConnectOpts[];
11
- sasl?: SASLOptions | null;
11
+ sasl?: SASLProvider | null;
12
12
  ssl?: TLSSocketOptions | null;
13
13
  };
14
14
 
@@ -3,24 +3,26 @@ import { readFileSync } from 'fs';
3
3
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
4
4
  import { API } from './api';
5
5
  import { KEY_TYPE } from './api/find-coordinator';
6
+ import { saslPlain } from './auth';
6
7
  import { createKafkaClient } from './client';
7
8
  import { Cluster } from './cluster';
8
9
  import { KafkaTSApiError } from './utils/error';
9
10
 
10
- export const kafka = createKafkaClient({
11
+ const kafka = createKafkaClient({
11
12
  clientId: 'kafka-ts',
12
13
  bootstrapServers: [{ host: 'localhost', port: 9092 }],
13
- sasl: { mechanism: 'PLAIN', username: 'admin', password: 'admin' },
14
+ sasl: saslPlain({ username: 'admin', password: 'admin' }),
14
15
  ssl: { ca: readFileSync('./certs/ca.crt').toString() },
15
16
  });
16
17
 
17
- describe.sequential('Request handler', () => {
18
+ describe.sequential('Low-level API', () => {
18
19
  const groupId = randomBytes(16).toString('hex');
19
20
 
20
21
  let cluster: Cluster;
21
22
 
22
23
  beforeAll(async () => {
23
- cluster = await kafka.createCluster().connect();
24
+ cluster = await kafka.createCluster();
25
+ await cluster.connect();
24
26
 
25
27
  const metadataResult = await cluster.sendRequest(API.METADATA, {
26
28
  topics: null,
@@ -51,8 +53,8 @@ describe.sequential('Request handler', () => {
51
53
  topics: [
52
54
  {
53
55
  name: 'kafka-ts-test-topic',
54
- numPartitions: 1,
55
- replicationFactor: 1,
56
+ numPartitions: 10,
57
+ replicationFactor: 3,
56
58
  assignments: [],
57
59
  configs: [],
58
60
  },
@@ -88,6 +90,7 @@ describe.sequential('Request handler', () => {
88
90
  expect(result).toMatchSnapshot();
89
91
  });
90
92
 
93
+ let partitionIndex = 0;
91
94
  let leaderId = 0;
92
95
 
93
96
  it('should request metadata for a topic', async () => {
@@ -96,6 +99,7 @@ describe.sequential('Request handler', () => {
96
99
  allowTopicAutoCreation: false,
97
100
  includeTopicAuthorizedOperations: false,
98
101
  });
102
+ partitionIndex = result.topics[0].partitions[0].partitionIndex;
99
103
  leaderId = result.topics[0].partitions[0].leaderId;
100
104
  result.controllerId = 0;
101
105
  result.topics.forEach((topic) => {
@@ -133,7 +137,7 @@ describe.sequential('Request handler', () => {
133
137
  name: 'kafka-ts-test-topic',
134
138
  partitionData: [
135
139
  {
136
- index: 0,
140
+ index: partitionIndex,
137
141
  baseOffset: 0n,
138
142
  partitionLeaderEpoch: 0,
139
143
  attributes: 0,
@@ -148,12 +152,12 @@ describe.sequential('Request handler', () => {
148
152
  attributes: 0,
149
153
  offsetDelta: 0,
150
154
  timestampDelta: 0n,
151
- key: 'key',
152
- value: 'value',
155
+ key: Buffer.from('key'),
156
+ value: Buffer.from('value'),
153
157
  headers: [
154
158
  {
155
- key: 'header-key',
156
- value: 'header-value',
159
+ key: Buffer.from('header-key'),
160
+ value: Buffer.from('header-value'),
157
161
  },
158
162
  ],
159
163
  },
@@ -179,7 +183,7 @@ describe.sequential('Request handler', () => {
179
183
  topicId,
180
184
  partitions: [
181
185
  {
182
- partition: 0,
186
+ partition: partitionIndex,
183
187
  currentLeaderEpoch: -1,
184
188
  fetchOffset: 0n,
185
189
  lastFetchedEpoch: 0,
@@ -317,8 +321,6 @@ describe.sequential('Request handler', () => {
317
321
  groups: [
318
322
  {
319
323
  groupId,
320
- memberId,
321
- memberEpoch: 0,
322
324
  topics: [
323
325
  {
324
326
  name: 'kafka-ts-test-topic',
package/src/cluster.ts CHANGED
@@ -1,85 +1,83 @@
1
1
  import { TcpSocketConnectOpts } from 'net';
2
2
  import { TLSSocketOptions } from 'tls';
3
3
  import { API } from './api';
4
- import { Broker, SASLOptions } from './broker';
4
+ import { Metadata } from './api/metadata';
5
+ import { Broker, SASLProvider } from './broker';
5
6
  import { SendRequest } from './connection';
6
- import { ConnectionError, KafkaTSError } from './utils/error';
7
+ import { KafkaTSError } from './utils/error';
8
+ import { log } from './utils/logger';
7
9
 
8
10
  type ClusterOptions = {
9
11
  clientId: string | null;
10
12
  bootstrapServers: TcpSocketConnectOpts[];
11
- sasl: SASLOptions | null;
13
+ sasl: SASLProvider | null;
12
14
  ssl: TLSSocketOptions | null;
13
15
  };
14
16
 
15
17
  export class Cluster {
16
- private seedBroker: Broker;
18
+ private seedBroker = new Broker({ clientId: null, sasl: null, ssl: null, options: { port: 9092 } });
17
19
  private brokerById: Record<number, Broker> = {};
20
+ private brokerMetadata: Record<number, Metadata['brokers'][number]> = {};
18
21
 
19
- constructor(private options: ClusterOptions) {
20
- this.seedBroker = new Broker({
21
- clientId: this.options.clientId,
22
- sasl: this.options.sasl,
23
- ssl: this.options.ssl,
24
- options: this.options.bootstrapServers[0],
25
- });
26
- }
22
+ constructor(private options: ClusterOptions) {}
27
23
 
28
24
  public async connect() {
29
- await this.connectSeedBroker();
25
+ this.seedBroker = await this.findSeedBroker();
26
+ this.brokerById = {};
27
+
30
28
  const metadata = await this.sendRequest(API.METADATA, {
31
29
  allowTopicAutoCreation: false,
32
30
  includeTopicAuthorizedOperations: false,
33
31
  topics: [],
34
32
  });
35
-
36
- this.brokerById = Object.fromEntries(
37
- metadata.brokers.map(({ nodeId, ...options }) => [
38
- nodeId,
39
- new Broker({
40
- clientId: this.options.clientId,
41
- sasl: this.options.sasl,
42
- ssl: this.options.ssl,
43
- options,
44
- }),
45
- ]),
46
- );
47
- return this;
33
+ this.brokerMetadata = Object.fromEntries(metadata.brokers.map((options) => [options.nodeId, options]));
48
34
  }
49
35
 
50
36
  public async disconnect() {
51
- await Promise.all([
52
- this.seedBroker.disconnect(),
53
- ...Object.values(this.brokerById).map((broker) => broker.disconnect()),
54
- ]);
37
+ await Promise.all([this.seedBroker.disconnect(), ...Object.values(this.brokerById).map((x) => x.disconnect())]);
55
38
  }
56
39
 
40
+ public setSeedBroker = async (nodeId: number) => {
41
+ await this.seedBroker.disconnect();
42
+ this.seedBroker = await this.acquireBroker(nodeId);
43
+ };
44
+
57
45
  public sendRequest: SendRequest = (...args) => this.seedBroker.sendRequest(...args);
58
46
 
59
47
  public sendRequestToNode =
60
48
  (nodeId: number): SendRequest =>
61
49
  async (...args) => {
62
- const broker = this.brokerById[nodeId];
63
- if (!broker) {
64
- throw new ConnectionError(`Broker ${nodeId} is not available`);
50
+ if (!this.brokerById[nodeId]) {
51
+ this.brokerById[nodeId] = await this.acquireBroker(nodeId);
65
52
  }
66
- await broker.ensureConnected();
67
- return broker.sendRequest(...args);
53
+ return this.brokerById[nodeId].sendRequest(...args);
68
54
  };
69
55
 
70
- private async connectSeedBroker() {
56
+ public async acquireBroker(nodeId: number) {
57
+ const broker = new Broker({
58
+ clientId: this.options.clientId,
59
+ sasl: this.options.sasl,
60
+ ssl: this.options.ssl,
61
+ options: this.brokerMetadata[nodeId],
62
+ });
63
+ await broker.connect();
64
+ return broker;
65
+ }
66
+
67
+ private async findSeedBroker() {
71
68
  const randomizedBrokers = this.options.bootstrapServers.toSorted(() => Math.random() - 0.5);
72
69
  for (const options of randomizedBrokers) {
73
70
  try {
74
- this.seedBroker = await new Broker({
71
+ const broker = await new Broker({
75
72
  clientId: this.options.clientId,
76
73
  sasl: this.options.sasl,
77
74
  ssl: this.options.ssl,
78
75
  options,
79
- }).connect();
80
- return;
76
+ });
77
+ await broker.connect();
78
+ return broker;
81
79
  } catch (error) {
82
- console.warn(`Failed to connect to seed broker ${options.host}:${options.port}`, error);
80
+ log.warn(`Failed to connect to seed broker ${options.host}:${options.port}`, error);
83
81
  }
84
82
  }
85
83
  throw new KafkaTSError('No seed brokers found');
@@ -0,0 +1,9 @@
1
+ import { gzip, unzip } from 'zlib';
2
+ import { Codec } from './types';
3
+
4
+ export const GZIP: Codec = {
5
+ compress: async (data) =>
6
+ new Promise<Buffer>((resolve, reject) => gzip(data, (err, result) => (err ? reject(err) : resolve(result)))),
7
+ decompress: async (data) =>
8
+ new Promise<Buffer>((resolve, reject) => unzip(data, (err, result) => (err ? reject(err) : resolve(result)))),
9
+ };
@@ -0,0 +1,16 @@
1
+ import { GZIP } from './gzip';
2
+ import { NONE } from './none';
3
+ import { Codec } from './types';
4
+
5
+ const codecs: Record<number, Codec> = {
6
+ 0: NONE,
7
+ 1: GZIP,
8
+ };
9
+
10
+ export const findCodec = (type: number) => {
11
+ const codec = codecs[type];
12
+ if (!codec) {
13
+ throw new Error(`Unsupported codec: ${type}`);
14
+ }
15
+ return codec;
16
+ };
@@ -0,0 +1,6 @@
1
+ import { Codec } from './types';
2
+
3
+ export const NONE: Codec = {
4
+ compress: (data: Buffer) => Promise.resolve(data),
5
+ decompress: (data: Buffer) => Promise.resolve(data),
6
+ };
@@ -0,0 +1,4 @@
1
+ export type Codec = {
2
+ compress: (data: Buffer) => Promise<Buffer>;
3
+ decompress: (data: Buffer) => Promise<Buffer>;
4
+ };
package/src/connection.ts CHANGED
@@ -6,11 +6,12 @@ import { Api } from './utils/api';
6
6
  import { Decoder } from './utils/decoder';
7
7
  import { Encoder } from './utils/encoder';
8
8
  import { ConnectionError } from './utils/error';
9
+ import { log } from './utils/logger';
9
10
  import { createTracer } from './utils/tracer';
10
11
 
11
12
  const trace = createTracer('Connection');
12
13
 
13
- export type ConnectionOptions = {
14
+ type ConnectionOptions = {
14
15
  clientId: string | null;
15
16
  connection: TcpSocketConnectOpts;
16
17
  ssl: TLSSocketOptions | null;
@@ -24,14 +25,14 @@ export class Connection {
24
25
  [correlationId: number]: { resolve: (response: RawResonse) => void; reject: (error: Error) => void };
25
26
  } = {};
26
27
  private lastCorrelationId = 0;
27
- private buffer: Buffer | null = null;
28
+ private chunks: Buffer[] = [];
28
29
 
29
30
  constructor(private options: ConnectionOptions) {}
30
31
 
31
32
  @trace()
32
33
  public async connect() {
33
34
  this.queue = {};
34
- this.buffer = null;
35
+ this.chunks = [];
35
36
 
36
37
  await new Promise<void>((resolve, reject) => {
37
38
  const { ssl, connection } = this.options;
@@ -59,6 +60,7 @@ export class Connection {
59
60
  });
60
61
  }
61
62
 
63
+ @trace()
62
64
  public disconnect() {
63
65
  this.socket.removeAllListeners();
64
66
  return new Promise<void>((resolve) => {
@@ -69,9 +71,10 @@ export class Connection {
69
71
  });
70
72
  }
71
73
 
72
- @trace((api, body) => ({ apiName: getApiName(api), body }))
74
+ @trace((api, body) => ({ message: getApiName(api), body }))
73
75
  public async sendRequest<Request, Response>(api: Api<Request, Response>, body: Request): Promise<Response> {
74
76
  const correlationId = this.nextCorrelationId();
77
+ const apiName = getApiName(api);
75
78
 
76
79
  const encoder = new Encoder()
77
80
  .writeInt16(api.apiKey)
@@ -79,18 +82,25 @@ export class Connection {
79
82
  .writeInt32(correlationId)
80
83
  .writeString(this.options.clientId);
81
84
 
82
- const request = api.request(encoder, body).value();
83
- const requestEncoder = new Encoder().writeInt32(request.length).write(request);
85
+ const request = api.request(encoder, body);
86
+ const requestEncoder = new Encoder().writeInt32(request.getByteLength()).writeEncoder(request);
84
87
 
88
+ let timeout: NodeJS.Timeout | undefined;
85
89
  const { responseDecoder, responseSize } = await new Promise<RawResonse>(async (resolve, reject) => {
90
+ timeout = setTimeout(() => {
91
+ delete this.queue[correlationId];
92
+ reject(new ConnectionError(`${apiName} timed out`));
93
+ }, 30_000);
94
+
86
95
  try {
87
- await this.write(requestEncoder.value());
88
96
  this.queue[correlationId] = { resolve, reject };
97
+ await this.write(requestEncoder.value());
89
98
  } catch (error) {
90
99
  reject(error);
91
100
  }
92
101
  });
93
- const response = api.response(responseDecoder);
102
+ clearTimeout(timeout);
103
+ const response = await api.response(responseDecoder);
94
104
 
95
105
  assert(
96
106
  responseDecoder.getOffset() - 4 === responseSize,
@@ -115,12 +125,13 @@ export class Connection {
115
125
  }
116
126
 
117
127
  private handleData(buffer: Buffer) {
118
- this.buffer = this.buffer ? Buffer.concat([this.buffer, buffer]) : buffer;
119
- if (this.buffer.length < 4) {
128
+ this.chunks.push(buffer);
129
+
130
+ const decoder = new Decoder(Buffer.concat(this.chunks));
131
+ if (decoder.getBufferLength() < 4) {
120
132
  return;
121
133
  }
122
134
 
123
- const decoder = new Decoder(this.buffer);
124
135
  const size = decoder.readInt32();
125
136
  if (size !== decoder.getBufferLength() - 4) {
126
137
  return;
@@ -128,15 +139,18 @@ export class Connection {
128
139
 
129
140
  const correlationId = decoder.readInt32();
130
141
 
131
- const { resolve } = this.queue[correlationId];
132
- delete this.queue[correlationId];
133
-
134
- resolve({ responseDecoder: decoder, responseSize: size });
135
- this.buffer = null;
142
+ const context = this.queue[correlationId];
143
+ if (context) {
144
+ delete this.queue[correlationId];
145
+ context.resolve({ responseDecoder: decoder, responseSize: size });
146
+ } else {
147
+ log.debug('Could not find pending request for correlationId', { correlationId });
148
+ }
149
+ this.chunks = [];
136
150
  }
137
151
 
138
152
  private nextCorrelationId() {
139
- return (this.lastCorrelationId = (this.lastCorrelationId + 1) % 2 ** 31);
153
+ return this.lastCorrelationId++;
140
154
  }
141
155
  }
142
156