@eventferry/kafka 3.3.0 → 3.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/index.cjs CHANGED
@@ -148,7 +148,10 @@ var UNSUPPORTED_BY_KAFKAJS = [
148
148
  "lingerMs",
149
149
  "batchSize",
150
150
  "deliveryTimeoutMs",
151
- "maxRequestSize"
151
+ "maxRequestSize",
152
+ // Confluent-only escape hatches; ignored on kafkajs.
153
+ "compressionLevel",
154
+ "rawProducerConfig"
152
155
  ];
153
156
  var KafkaJsDriver = class {
154
157
  transactional;
@@ -187,13 +190,21 @@ var KafkaJsDriver = class {
187
190
  // the provider's returned token (other fields are ignored).
188
191
  sasl: this.opts.sasl
189
192
  });
190
- const createPartitioner = resolveCreatePartitioner(
191
- mod.Partitioners,
193
+ return kafka.producer(await this.buildProducerOptions(mod.Partitioners));
194
+ }
195
+ /**
196
+ * Compute the options object passed to `kafka.producer({...})`. Exposed
197
+ * as a test seam so power-user escape hatches (customPartitioner,
198
+ * rawKafkaJsProducerConfig) can be asserted without a live broker.
199
+ */
200
+ async buildProducerOptions(partitioners) {
201
+ const createPartitioner = this.opts.customPartitioner ?? resolveCreatePartitioner(
202
+ partitioners,
192
203
  this.opts.partitioner,
193
204
  this.transactional
194
205
  );
195
206
  const resolvedTxId = this.transactional ? await resolveTransactionalId(this.opts.transactionalId) : void 0;
196
- return kafka.producer({
207
+ return {
197
208
  idempotent: this.opts.idempotent ?? true,
198
209
  // Idempotent / transactional producers cap maxInFlight at 5. When the
199
210
  // user picks transactional we force 1 to keep strict ordering across
@@ -207,13 +218,32 @@ var KafkaJsDriver = class {
207
218
  transactionTimeout: this.opts.transactionTimeoutMs,
208
219
  // Setting any partitioner choice silences kafkajs's
209
220
  // KafkaJSPartitionerNotSpecified warning.
210
- createPartitioner
211
- });
221
+ createPartitioner,
222
+ // Power-user escape hatch — merged LAST so raw keys win against the
223
+ // translated ones. That's the contract: anything you put here is
224
+ // final, even if it overrides idempotent/transactionalId/etc.
225
+ ...this.opts.rawKafkaJsProducerConfig ?? {}
226
+ };
212
227
  }
213
228
  async disconnect() {
214
229
  await this.producer?.disconnect();
215
230
  this.producer = null;
216
231
  }
232
+ /**
233
+ * Construct a kafkajs admin client wrapped in the eventferry-facing
234
+ * `KafkaDriverAdmin` shape. The publisher calls `.connect()` on the
235
+ * returned object before exposing it via `publisher.admin()`.
236
+ */
237
+ async admin() {
238
+ const mod = await importKafkaJs();
239
+ const kafka = new mod.Kafka({
240
+ clientId: this.opts.clientId ?? "eventferry-admin",
241
+ brokers: this.opts.brokers,
242
+ ssl: this.opts.ssl,
243
+ sasl: this.opts.sasl
244
+ });
245
+ return new KafkaJsAdmin(kafka.admin());
246
+ }
217
247
  async sendBatch(messages) {
218
248
  if (!this.producer) throw new Error("KafkaJsDriver not connected");
219
249
  const topicMessages = groupByTopic(messages, this.opts.compression);
@@ -302,6 +332,69 @@ function warnUnsupportedKafkajsOptions(opts) {
302
332
  function _resetKafkajsWarnDedup() {
303
333
  warnedKafkajsKeys.clear();
304
334
  }
335
+ var KafkaJsAdmin = class {
336
+ constructor(client) {
337
+ this.client = client;
338
+ }
339
+ client;
340
+ async connect() {
341
+ await this.client.connect();
342
+ }
343
+ async close() {
344
+ await this.client.disconnect();
345
+ }
346
+ async listTopics() {
347
+ return await this.client.listTopics();
348
+ }
349
+ async describeTopics(topics) {
350
+ if (topics.length === 0) return [];
351
+ const all = new Set(await this.client.listTopics());
352
+ const existing = topics.filter((t) => all.has(t));
353
+ const missing = topics.filter((t) => !all.has(t));
354
+ const meta = existing.length ? await this.client.fetchTopicMetadata({ topics: existing }) : { topics: [] };
355
+ const byName = new Map(meta.topics.map((t) => [t.name, t]));
356
+ return topics.map((topic) => {
357
+ if (missing.includes(topic)) return { topic, partitions: [] };
358
+ const found = byName.get(topic);
359
+ if (!found) return { topic, partitions: [] };
360
+ return {
361
+ topic,
362
+ partitions: found.partitions.map((p) => ({
363
+ partitionId: p.partitionId,
364
+ leader: p.leader,
365
+ replicas: p.replicas,
366
+ isr: p.isr
367
+ }))
368
+ };
369
+ });
370
+ }
371
+ async createTopics(specs) {
372
+ if (specs.length === 0) return;
373
+ const topics = specs.map((s) => ({
374
+ topic: s.topic,
375
+ numPartitions: s.numPartitions,
376
+ replicationFactor: s.replicationFactor,
377
+ configEntries: s.configEntries ? Object.entries(s.configEntries).map(([name, value]) => ({ name, value })) : void 0
378
+ }));
379
+ try {
380
+ await this.client.createTopics({ topics, waitForLeaders: true });
381
+ } catch (err) {
382
+ const e = err;
383
+ if (e?.type === "TOPIC_ALREADY_EXISTS") return;
384
+ if (/already exists/i.test(e?.message ?? "")) return;
385
+ throw err;
386
+ }
387
+ }
388
+ async createPartitions(specs) {
389
+ if (specs.length === 0) return;
390
+ await this.client.createPartitions({
391
+ topicPartitions: specs.map((s) => ({
392
+ topic: s.topic,
393
+ count: s.totalCount
394
+ }))
395
+ });
396
+ }
397
+ };
305
398
  async function importKafkaJs() {
306
399
  try {
307
400
  return await import("kafkajs");
@@ -434,6 +527,9 @@ function buildConfluentClientConfig(opts) {
434
527
  if (opts.transactionTimeoutMs !== void 0) {
435
528
  librdkafka["transaction.timeout.ms"] = opts.transactionTimeoutMs;
436
529
  }
530
+ if (opts.compressionLevel !== void 0) {
531
+ librdkafka["compression.level"] = opts.compressionLevel;
532
+ }
437
533
  const tlsRequested = opts.ssl === true || isTlsConfig(opts.ssl);
438
534
  const saslRequested = !!opts.sasl;
439
535
  if (saslRequested && tlsRequested) {
@@ -463,6 +559,9 @@ function buildConfluentClientConfig(opts) {
463
559
  if (opts.sasl) {
464
560
  kafkaJS["sasl"] = opts.sasl;
465
561
  }
562
+ if (opts.rawProducerConfig) {
563
+ Object.assign(librdkafka, opts.rawProducerConfig);
564
+ }
466
565
  return { kafkaJS, librdkafka };
467
566
  }
468
567
  function isTlsConfig(v) {
@@ -516,6 +615,17 @@ var ConfluentDriver = class {
516
615
  await this.producer?.disconnect();
517
616
  this.producer = null;
518
617
  }
618
+ /**
619
+ * Construct a librdkafka-backed admin client wrapped in the eventferry
620
+ * `KafkaDriverAdmin` shape. The publisher's `connect()` is called before
621
+ * the admin reaches the user.
622
+ */
623
+ async admin() {
624
+ const mod = await importConfluent();
625
+ const { kafkaJS, librdkafka } = buildConfluentClientConfig(this.opts);
626
+ const kafka = new mod.KafkaJS.Kafka({ kafkaJS, ...librdkafka });
627
+ return new ConfluentAdmin(kafka.admin());
628
+ }
519
629
  async sendBatch(messages) {
520
630
  if (!this.producer) throw new Error("ConfluentDriver not connected");
521
631
  const topicMessages = groupByTopic2(messages);
@@ -584,6 +694,69 @@ function groupByTopic2(messages) {
584
694
  messages: msgs
585
695
  }));
586
696
  }
697
+ var ConfluentAdmin = class {
698
+ constructor(client) {
699
+ this.client = client;
700
+ }
701
+ client;
702
+ async connect() {
703
+ await this.client.connect();
704
+ }
705
+ async close() {
706
+ await this.client.disconnect();
707
+ }
708
+ async listTopics() {
709
+ return await this.client.listTopics();
710
+ }
711
+ async describeTopics(topics) {
712
+ if (topics.length === 0) return [];
713
+ const all = new Set(await this.client.listTopics());
714
+ const existing = topics.filter((t) => all.has(t));
715
+ const missing = topics.filter((t) => !all.has(t));
716
+ const meta = existing.length ? await this.client.fetchTopicMetadata({ topics: existing }) : { topics: [] };
717
+ const byName = new Map(meta.topics.map((t) => [t.name, t]));
718
+ return topics.map((topic) => {
719
+ if (missing.includes(topic)) return { topic, partitions: [] };
720
+ const found = byName.get(topic);
721
+ if (!found) return { topic, partitions: [] };
722
+ return {
723
+ topic,
724
+ partitions: found.partitions.map((p) => ({
725
+ partitionId: p.partitionId,
726
+ leader: p.leader,
727
+ replicas: p.replicas,
728
+ isr: p.isr
729
+ }))
730
+ };
731
+ });
732
+ }
733
+ async createTopics(specs) {
734
+ if (specs.length === 0) return;
735
+ const topics = specs.map((s) => ({
736
+ topic: s.topic,
737
+ numPartitions: s.numPartitions,
738
+ replicationFactor: s.replicationFactor,
739
+ configEntries: s.configEntries ? Object.entries(s.configEntries).map(([name, value]) => ({ name, value })) : void 0
740
+ }));
741
+ try {
742
+ await this.client.createTopics({ topics, waitForLeaders: true });
743
+ } catch (err) {
744
+ const e = err;
745
+ if (e?.code === 36 || e?.name === "TOPIC_ALREADY_EXISTS") return;
746
+ if (/already exists/i.test(e?.message ?? "")) return;
747
+ throw err;
748
+ }
749
+ }
750
+ async createPartitions(specs) {
751
+ if (specs.length === 0) return;
752
+ await this.client.createPartitions({
753
+ topicPartitions: specs.map((s) => ({
754
+ topic: s.topic,
755
+ count: s.totalCount
756
+ }))
757
+ });
758
+ }
759
+ };
587
760
  async function importConfluent() {
588
761
  try {
589
762
  return await import("@confluentinc/kafka-javascript");
@@ -634,10 +807,12 @@ var KafkaPublisher = class {
634
807
  logger;
635
808
  hooks;
636
809
  tracer;
810
+ validateTopicsOnConnect;
637
811
  constructor(opts) {
638
812
  this.logger = opts.logger;
639
813
  this.hooks = opts.hooks ?? {};
640
814
  this.tracer = opts.tracer ?? new NoopKafkaTracer();
815
+ this.validateTopicsOnConnect = opts.validateTopicsOnConnect ? Object.freeze([...opts.validateTopicsOnConnect]) : void 0;
641
816
  const onTransactionAbort = this.hooks.onTransactionAbort ? (error) => {
642
817
  void safeHook(
643
818
  this.logger,
@@ -649,8 +824,90 @@ var KafkaPublisher = class {
649
824
  }
650
825
  async connect() {
651
826
  await this.driver.connect();
827
+ if (this.validateTopicsOnConnect && this.validateTopicsOnConnect.length) {
828
+ await this.assertTopicsExist(this.validateTopicsOnConnect);
829
+ }
652
830
  await safeHook(this.logger, "onConnect", () => this.hooks.onConnect?.());
653
831
  }
832
+ /**
833
+ * Borrow a new admin client from the driver. The returned admin is
834
+ * connected and ready to use; the CALLER must `close()` it. Throws if the
835
+ * driver does not implement admin (custom driver lacking the capability).
836
+ */
837
+ async admin() {
838
+ const driverAdmin = await this.openDriverAdmin();
839
+ return driverAdmin;
840
+ }
841
+ /**
842
+ * Idempotently provision topics. Each spec creates the topic if absent;
843
+ * existing topics are skipped without error. If `growPartitions: true`
844
+ * (default false), topics whose current partition count is below the
845
+ * requested `numPartitions` are grown via `createPartitions`.
846
+ *
847
+ * Replication factor and config entries on EXISTING topics are NOT
848
+ * reconciled — Kafka does not provide a safe in-place alter for those
849
+ * (changing replication requires reassignment; configs use alterConfigs).
850
+ * Reach for the raw admin if you need that.
851
+ */
852
+ async ensureTopics(specs, opts = {}) {
853
+ if (specs.length === 0) return;
854
+ const admin = await this.openDriverAdmin();
855
+ try {
856
+ const topicNames = specs.map((s) => s.topic);
857
+ const existing = await admin.describeTopics(topicNames);
858
+ const existingByName = new Map(existing.map((t) => [t.topic, t]));
859
+ const toCreate = specs.filter(
860
+ (s) => (existingByName.get(s.topic)?.partitions.length ?? 0) === 0
861
+ );
862
+ if (toCreate.length) await admin.createTopics(toCreate);
863
+ if (opts.growPartitions) {
864
+ const grow = [];
865
+ for (const s of specs) {
866
+ if (s.numPartitions === void 0) continue;
867
+ const current = existingByName.get(s.topic);
868
+ const currentCount = current?.partitions.length ?? 0;
869
+ if (currentCount > 0 && currentCount < s.numPartitions) {
870
+ grow.push({ topic: s.topic, totalCount: s.numPartitions });
871
+ }
872
+ }
873
+ if (grow.length) await admin.createPartitions(grow);
874
+ }
875
+ } finally {
876
+ await admin.close();
877
+ }
878
+ }
879
+ /**
880
+ * Borrow a fresh admin from the driver and connect it. Throws when the
881
+ * driver does not implement admin (custom drivers without that capability).
882
+ */
883
+ async openDriverAdmin() {
884
+ if (!this.driver.admin) {
885
+ throw new Error(
886
+ "KafkaPublisher: configured driver does not implement admin(). Use the built-in kafkajs or confluent driver, or extend your custom driver."
887
+ );
888
+ }
889
+ const admin = await this.driver.admin();
890
+ await admin.connect();
891
+ return admin;
892
+ }
893
+ /**
894
+ * Open an admin, list topics, throw if any required topic is missing.
895
+ * Always closes the admin (success or failure).
896
+ */
897
+ async assertTopicsExist(required) {
898
+ const admin = await this.openDriverAdmin();
899
+ try {
900
+ const all = new Set(await admin.listTopics());
901
+ const missing = required.filter((t) => !all.has(t));
902
+ if (missing.length) {
903
+ throw new Error(
904
+ `KafkaPublisher: validateTopicsOnConnect failed \u2014 topics missing on cluster: ${missing.join(", ")}`
905
+ );
906
+ }
907
+ } finally {
908
+ await admin.close();
909
+ }
910
+ }
654
911
  async disconnect() {
655
912
  await this.driver.disconnect();
656
913
  await safeHook(
@@ -662,9 +919,14 @@ var KafkaPublisher = class {
662
919
  async publish(messages) {
663
920
  if (messages.length === 0) return [];
664
921
  const span = this.startBatchSpan(messages);
922
+ const outgoing = this.tracer.inject ? messages.map((m) => {
923
+ const headers = { ...m.headers };
924
+ this.tracer.inject(span, headers);
925
+ return { ...m, headers };
926
+ }) : messages;
665
927
  let results;
666
928
  try {
667
- results = await this.driver.sendBatch(messages);
929
+ results = await this.driver.sendBatch(outgoing);
668
930
  } catch (err) {
669
931
  const error = err instanceof Error ? err : new Error(String(err));
670
932
  span.setStatus({ code: "error", message: error.message });