kafka-console 3.0.0 → 3.1.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.
@@ -1,7 +1,7 @@
1
1
  import * as Fs from 'fs';
2
2
  import { finished } from 'node:stream/promises';
3
- import { Consumer, stringDeserializers, MessagesStreamModes, ListOffsetTimestamps } from '@platformatic/kafka';
4
- import { getClientConfigFromOpts } from "../utils/kafka.js";
3
+ import { Consumer, stringDeserializers, ListOffsetTimestamps } from '@platformatic/kafka';
4
+ import { getClientConfigFromOpts, resolveConsumeMode } from "../utils/kafka.js";
5
5
  import { getFormatter } from "../utils/formatters.js";
6
6
  function toError(error) {
7
7
  return error instanceof Error ? error : new Error(String(error));
@@ -43,38 +43,15 @@ export default async function consume(topic, opts, { parent }) {
43
43
  let cleanupError;
44
44
  let aborted = false;
45
45
  try {
46
- let mode = MessagesStreamModes.LATEST;
47
- let offsets;
48
- if (opts.snapshot && !opts.from) {
49
- mode = MessagesStreamModes.EARLIEST;
50
- }
51
- else if (opts.from === '0') {
52
- mode = MessagesStreamModes.EARLIEST;
53
- }
54
- else if (opts.from) {
55
- const ts = /^\d+$/.test(opts.from) ? parseInt(opts.from, 10) : Date.parse(opts.from);
56
- if (Number.isNaN(ts)) {
57
- throw new Error(`Invalid timestamp "${opts.from}"`);
58
- }
59
- const offsetsMap = await consumer.listOffsets({ topics: [topic], timestamp: BigInt(ts) });
60
- const partitionOffsets = offsetsMap.get(topic);
61
- if (partitionOffsets) {
62
- offsets = [];
63
- for (let partition = 0; partition < partitionOffsets.length; partition++) {
64
- offsets.push({ topic, partition, offset: partitionOffsets[partition] });
65
- }
66
- mode = MessagesStreamModes.MANUAL;
67
- }
68
- }
46
+ const fromArg = opts.snapshot && !opts.from ? '0' : opts.from;
47
+ const { mode, offsets } = await resolveConsumeMode(consumer, topic, fromArg);
69
48
  const consumeOptions = {
70
49
  topics: [topic],
71
50
  mode,
51
+ offsets,
72
52
  sessionTimeout: 30000,
73
53
  heartbeatInterval: 1000,
74
54
  };
75
- if (offsets) {
76
- consumeOptions.offsets = offsets;
77
- }
78
55
  try {
79
56
  stream = await consumer.consume(consumeOptions);
80
57
  }
@@ -0,0 +1,81 @@
1
+ import { Consumer, Producer, stringDeserializers, stringSerializers, ListOffsetTimestamps } from '@platformatic/kafka';
2
+ import { getClientConfigFromOpts, resolveConsumeMode } from "../utils/kafka.js";
3
+ export default async function copyTopic(source, dest, opts, { parent }) {
4
+ const config = getClientConfigFromOpts(parent.opts());
5
+ const consumer = new Consumer({
6
+ ...config,
7
+ groupId: opts.group,
8
+ deserializers: stringDeserializers,
9
+ autocommit: true,
10
+ });
11
+ const producer = new Producer({
12
+ ...config,
13
+ serializers: stringSerializers,
14
+ compression: opts.compression,
15
+ });
16
+ let stream;
17
+ let interrupted = false;
18
+ const handleSignal = () => {
19
+ interrupted = true;
20
+ if (stream)
21
+ void stream.close();
22
+ };
23
+ process.once('SIGINT', handleSignal);
24
+ process.once('SIGTERM', handleSignal);
25
+ try {
26
+ const fromArg = opts.from ?? '0';
27
+ const { mode, offsets } = await resolveConsumeMode(consumer, source, fromArg);
28
+ const latestMap = await consumer.listOffsets({ topics: [source], timestamp: ListOffsetTimestamps.LATEST });
29
+ const highWatermarks = latestMap.get(source) || [];
30
+ const remaining = new Set();
31
+ for (let p = 0; p < highWatermarks.length; p++) {
32
+ if (highWatermarks[p] > 0n)
33
+ remaining.add(p);
34
+ }
35
+ if (remaining.size === 0) {
36
+ console.error('0 messages copied');
37
+ return;
38
+ }
39
+ stream = await consumer.consume({
40
+ topics: [source],
41
+ mode,
42
+ offsets,
43
+ sessionTimeout: 30000,
44
+ heartbeatInterval: 1000,
45
+ });
46
+ let batch = [];
47
+ let total = 0;
48
+ for await (const message of stream) {
49
+ if (interrupted)
50
+ break;
51
+ batch.push({
52
+ topic: dest,
53
+ key: message.key ?? undefined,
54
+ value: message.value ?? undefined,
55
+ headers: message.headers,
56
+ });
57
+ total++;
58
+ if (message.offset >= highWatermarks[message.partition] - 1n) {
59
+ remaining.delete(message.partition);
60
+ }
61
+ if (batch.length >= opts.batchSize || remaining.size === 0 || total >= opts.count) {
62
+ await producer.send({ messages: batch });
63
+ batch = [];
64
+ if (remaining.size === 0 || total >= opts.count)
65
+ break;
66
+ }
67
+ }
68
+ if (batch.length > 0) {
69
+ await producer.send({ messages: batch });
70
+ }
71
+ console.error(`${total} messages copied`);
72
+ }
73
+ finally {
74
+ process.off('SIGINT', handleSignal);
75
+ process.off('SIGTERM', handleSignal);
76
+ if (stream)
77
+ await stream.close().catch(() => { });
78
+ await consumer.close().catch(() => { });
79
+ await producer.close().catch(() => { });
80
+ }
81
+ }
@@ -0,0 +1,75 @@
1
+ import * as Fs from 'fs';
2
+ import { finished } from 'node:stream/promises';
3
+ import { Consumer, stringDeserializers, ListOffsetTimestamps } from '@platformatic/kafka';
4
+ import { getClientConfigFromOpts, resolveConsumeMode } from "../utils/kafka.js";
5
+ export default async function dumpTopic(topic, opts, { parent }) {
6
+ const config = getClientConfigFromOpts(parent.opts());
7
+ const output = Fs.createWriteStream(opts.output);
8
+ const consumer = new Consumer({
9
+ ...config,
10
+ groupId: opts.group,
11
+ deserializers: stringDeserializers,
12
+ autocommit: true,
13
+ });
14
+ let stream;
15
+ let interrupted = false;
16
+ const handleSignal = () => {
17
+ interrupted = true;
18
+ if (stream)
19
+ void stream.close();
20
+ };
21
+ process.once('SIGINT', handleSignal);
22
+ process.once('SIGTERM', handleSignal);
23
+ try {
24
+ const fromArg = opts.from ?? '0';
25
+ const { mode, offsets } = await resolveConsumeMode(consumer, topic, fromArg);
26
+ const latestMap = await consumer.listOffsets({ topics: [topic], timestamp: ListOffsetTimestamps.LATEST });
27
+ const highWatermarks = latestMap.get(topic) || [];
28
+ const remaining = new Set();
29
+ for (let p = 0; p < highWatermarks.length; p++) {
30
+ if (highWatermarks[p] > 0n)
31
+ remaining.add(p);
32
+ }
33
+ if (remaining.size === 0) {
34
+ console.error(`0 messages dumped to ${opts.output}`);
35
+ return;
36
+ }
37
+ stream = await consumer.consume({
38
+ topics: [topic],
39
+ mode,
40
+ offsets,
41
+ sessionTimeout: 30000,
42
+ heartbeatInterval: 1000,
43
+ });
44
+ let total = 0;
45
+ for await (const message of stream) {
46
+ if (interrupted)
47
+ break;
48
+ const msg = {
49
+ partition: message.partition,
50
+ offset: message.offset.toString(),
51
+ timestamp: message.timestamp.toString(),
52
+ headers: Object.fromEntries(message.headers),
53
+ key: message.key,
54
+ value: message.value,
55
+ };
56
+ output.write(JSON.stringify(msg) + '\n');
57
+ total++;
58
+ if (message.offset >= highWatermarks[message.partition] - 1n) {
59
+ remaining.delete(message.partition);
60
+ }
61
+ if (remaining.size === 0 || total >= opts.count)
62
+ break;
63
+ }
64
+ console.error(`${total} messages dumped to ${opts.output}`);
65
+ }
66
+ finally {
67
+ process.off('SIGINT', handleSignal);
68
+ process.off('SIGTERM', handleSignal);
69
+ if (stream)
70
+ await stream.close().catch(() => { });
71
+ await consumer.close().catch(() => { });
72
+ output.end();
73
+ await finished(output).catch(() => { });
74
+ }
75
+ }
@@ -55,6 +55,7 @@ export default async function produce(topic, opts, { parent }) {
55
55
  const producer = new Producer({
56
56
  ...config,
57
57
  serializers: stringSerializers,
58
+ compression: opts.compression,
58
59
  });
59
60
  const staticHeaders = {};
60
61
  for (const h of opts.header) {
package/build/index.js CHANGED
@@ -8,6 +8,8 @@ import configCommand from "./commands/config.js";
8
8
  import createTopicCommand from "./commands/createTopic.js";
9
9
  import deleteTopicCommand from "./commands/deleteTopic.js";
10
10
  import fetchTopicOffsets from "./commands/fetchTopicOffsets.js";
11
+ import copyTopicCommand from "./commands/copyTopic.js";
12
+ import dumpTopicCommand from "./commands/dumpTopic.js";
11
13
  import { createRequire } from 'node:module';
12
14
  const { version } = createRequire(import.meta.url)('../package.json');
13
15
  export function collect(value, previous) {
@@ -47,6 +49,7 @@ commander
47
49
  .option('-i, --input <filename>', 'read messages from a JSON array file instead of stdin')
48
50
  .option('-w, --wait <wait>', 'delay in milliseconds between sending each message', toInt, 0)
49
51
  .option('-H, --header <header>', 'static header added to every message (format: key:value), repeatable', collect, [])
52
+ .option('-C, --compression <algorithm>', 'compression algorithm: none, gzip, snappy, lz4, zstd')
50
53
  .action(produceCommand);
51
54
  commander.command('metadata').description('Display cluster metadata: broker list, controller, topic partitions, replicas, and ISR').action(metadataCommand);
52
55
  commander
@@ -73,6 +76,23 @@ commander
73
76
  .description('Show topic partition offsets. Without arguments shows high/low watermarks. With a timestamp (ms or ISO 8601) shows offsets at that point. With --group shows committed offsets for a consumer group.')
74
77
  .option('-g, --group <group>', 'show committed offsets for this consumer group instead of watermarks')
75
78
  .action(fetchTopicOffsets);
79
+ commander
80
+ .command('topic:copy <source> <dest>')
81
+ .description('Copy messages from one topic to another. Reads all messages from <source> (snapshot mode) and writes them to <dest> in batches. Preserves keys, values, and headers. Exits when all existing messages are copied.')
82
+ .option('-g, --group <group>', 'consumer group name', `kafka-console-copy-${Date.now()}`)
83
+ .option('-f, --from <from>', 'start from a timestamp (ms), ISO 8601 date, or 0 for the beginning', '0')
84
+ .option('-c, --count <count>', 'maximum number of messages to copy', toInt, Infinity)
85
+ .option('--batch-size <size>', 'number of messages per producer send call', toInt, 500)
86
+ .option('-C, --compression <algorithm>', 'producer compression: none, gzip, snappy, lz4, zstd')
87
+ .action(copyTopicCommand);
88
+ commander
89
+ .command('topic:dump <topic>')
90
+ .description('Dump messages from a topic to a JSONL file. Each line contains partition, offset, timestamp, headers, key, and the raw value string. Exits when all existing messages are written.')
91
+ .requiredOption('-o, --output <filename>', 'output file path')
92
+ .option('-g, --group <group>', 'consumer group name', `kafka-console-dump-${Date.now()}`)
93
+ .option('-f, --from <from>', 'start from a timestamp (ms), ISO 8601 date, or 0 for the beginning', '0')
94
+ .option('-c, --count <count>', 'maximum number of messages to dump', toInt, Infinity)
95
+ .action(dumpTopicCommand);
76
96
  commander.parseAsync(process.argv).catch((e) => {
77
97
  console.error(e.message);
78
98
  process.exit(1);
@@ -1,4 +1,4 @@
1
- import { Admin, ConfigResourceTypes, Consumer, stringDeserializers, ListOffsetTimestamps } from '@platformatic/kafka';
1
+ import { Admin, ConfigResourceTypes, Consumer, stringDeserializers, ListOffsetTimestamps, MessagesStreamModes } from '@platformatic/kafka';
2
2
  export function resourceParser(resource) {
3
3
  if (/^any$/i.test(resource)) {
4
4
  return 'UNKNOWN';
@@ -94,6 +94,25 @@ export async function fetchTopicOffsetsByTimestamp(config, topic, timestamp) {
94
94
  await consumer.close();
95
95
  }
96
96
  }
97
+ export async function resolveConsumeMode(consumer, topic, from) {
98
+ if (!from || from === '0') {
99
+ return { mode: from === '0' ? MessagesStreamModes.EARLIEST : MessagesStreamModes.LATEST };
100
+ }
101
+ const ts = /^\d+$/.test(from) ? parseInt(from, 10) : Date.parse(from);
102
+ if (Number.isNaN(ts)) {
103
+ throw new Error(`Invalid timestamp "${from}"`);
104
+ }
105
+ const offsetsMap = await consumer.listOffsets({ topics: [topic], timestamp: BigInt(ts) });
106
+ const partitionOffsets = offsetsMap.get(topic);
107
+ if (!partitionOffsets) {
108
+ return { mode: MessagesStreamModes.LATEST };
109
+ }
110
+ const offsets = [];
111
+ for (let partition = 0; partition < partitionOffsets.length; partition++) {
112
+ offsets.push({ topic, partition, offset: partitionOffsets[partition] });
113
+ }
114
+ return { mode: MessagesStreamModes.MANUAL, offsets };
115
+ }
97
116
  export async function fetchConsumerGroupOffsets(config, groupId, topics) {
98
117
  const admin = new Admin(config);
99
118
  let meta;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kafka-console",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Kafka CLI tool",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,12 +29,12 @@
29
29
  "@eslint/js": "^10.0.1",
30
30
  "@types/debug": "^4.1.13",
31
31
  "@types/node": "^25.5.0",
32
- "@vitest/coverage-v8": "^4.1.0",
33
- "eslint": "^10.0.3",
32
+ "@vitest/coverage-v8": "^4.1.2",
33
+ "eslint": "^10.1.0",
34
34
  "eslint-plugin-prettier": "^5.5.5",
35
- "typescript": "^5.9.3",
36
- "typescript-eslint": "^8.57.1",
37
- "vitest": "^4.1.0"
35
+ "typescript": "^6.0.2",
36
+ "typescript-eslint": "^8.57.2",
37
+ "vitest": "^4.1.2"
38
38
  },
39
39
  "dependencies": {
40
40
  "@platformatic/kafka": "^1.31.0",