feat(Kafka Node): Add version 2 to send messages via the new Kafka library (#35450)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yen Su
2026-08-05 06:13:45 -07:00
committed by GitHub
parent e888b41b75
commit d2901bd02d
8 changed files with 1248 additions and 10 deletions
@@ -2,6 +2,7 @@ import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow'
import { VersionedNodeType } from 'n8n-workflow';
import { KafkaV1 } from './v1/KafkaV1.node';
import { KafkaV2 } from './v2/KafkaV2.node';
export class Kafka extends VersionedNodeType {
constructor() {
@@ -16,6 +17,7 @@ export class Kafka extends VersionedNodeType {
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new KafkaV1(baseDescription),
2: new KafkaV2(baseDescription),
};
super(nodeVersions, baseDescription);
@@ -11,6 +11,12 @@ import { mock } from 'vitest-mock-extended';
import { Kafka } from '../Kafka.node';
import { KafkaV1 } from '../v1/KafkaV1.node';
import { KafkaV2 } from '../v2/KafkaV2.node';
import {
confluentKafkaModuleMock,
getConfluentKafkaAccessCount,
resetConfluentKafkaAccessCount,
} from './mocks/confluent-kafka';
// The node is imported directly (through vite) so vi.mock can intercept its
// `kafkajs` / `@kafkajs/confluent-schema-registry` imports. NodeTestHarness can't
@@ -91,6 +97,11 @@ vi.mock('@kafkajs/confluent-schema-registry', () => ({
}),
}));
// v1 must never load the new library — the ESLint import restrictions guard the
// static-import side; this covers the runtime side (e.g. a dynamic import added
// by mistake down the line).
vi.mock('@confluentinc/kafka-javascript', () => confluentKafkaModuleMock());
const defaultKafkaCredentials: IDataObject = {
brokers: 'localhost:9092',
clientId: 'test-client',
@@ -145,6 +156,24 @@ const schemaRegistryCredential = {
describe('Kafka Node', () => {
beforeEach(() => {
vi.clearAllMocks();
resetConfluentKafkaAccessCount();
});
test('never loads the new confluent-kafka-javascript library', async () => {
const params: IDataObject = {
options: { acks: true, compression: true, timeout: 1000 },
sendInputData: true,
useSchemaRegistry: false,
topic: 'test-topic',
jsonParameters: false,
useKey: false,
headersUi: {},
};
const items: INodeExecutionData[] = [{ json: { name: 'item' } }];
await new KafkaV1(baseDescription).execute.call(createExecuteFunctions(params, items));
expect(getConfluentKafkaAccessCount()).toBe(0);
});
test('publishes input data as messages with key, headers and options', async () => {
@@ -402,6 +431,30 @@ describe('Kafka (versioned entry point)', () => {
expect(kafka.nodeVersions[1]).toBeInstanceOf(KafkaV1);
});
it('should expose version 2 as KafkaV2', () => {
expect(kafka.nodeVersions[2]).toBeInstanceOf(KafkaV2);
});
// One credential test per credential type, not per node version. Adding
// `methods.credentialTest.kafkaConnectionTest` to v2 wouldn't add a second test — it is
// resolved newest-version-first, so it would take over v1's test for everyone. Until it
// moves, the test exercises v1's kafkajs path while v2 connects through librdkafka.
it('should leave the kafka credential test to v1', () => {
const v2 = kafka.nodeVersions[2];
expect(v2.methods?.credentialTest).toBeUndefined();
expect(v2.description.credentials?.find((c) => c.name === 'kafka')?.testedBy).toBeUndefined();
expect(kafka.nodeVersions[1].methods?.credentialTest).toHaveProperty('kafkaConnectionTest');
});
it('should resolve v1 by default', () => {
expect(kafka.getNodeType()).toBeInstanceOf(KafkaV1);
});
it('should resolve v2 when requested', () => {
expect(kafka.getNodeType(2)).toBeInstanceOf(KafkaV2);
});
it('should have defaultVersion set to 1', () => {
expect(kafka.description.defaultVersion).toBe(1);
});
@@ -8,14 +8,20 @@ export function confluentKafkaModuleMock(): { readonly KafkaJS: unknown } {
get KafkaJS() {
accessCount += 1;
return {
Kafka: vi.fn().mockImplementation((config?: unknown) => ({
config,
connect: vi.fn(),
disconnect: vi.fn(),
producer: vi.fn(),
consumer: vi.fn(),
admin: vi.fn(),
})),
Kafka: vi.fn().mockImplementation(function (config?: unknown) {
return {
config,
connect: vi.fn(),
disconnect: vi.fn(),
producer: vi.fn(() => ({
connect: vi.fn(),
sendBatch: vi.fn(),
disconnect: vi.fn(),
})),
consumer: vi.fn(),
admin: vi.fn(),
};
}),
logLevel: { NOTHING: 0, ERROR: 1, WARN: 2, INFO: 3, DEBUG: 4 },
};
},
@@ -0,0 +1,595 @@
import { SchemaRegistry } from '@kafkajs/confluent-schema-registry';
import type {
IDataObject,
IExecuteFunctions,
INode,
INodeExecutionData,
INodeTypeBaseDescription,
} from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { KafkaV2 } from '../../v2/KafkaV2.node';
import { getKafkaLibrary } from '../../v2/transport/client';
import { confluentKafkaModuleMock } from '../mocks/confluent-kafka';
// Same reasoning as the v1 test file: the node is imported directly (through vite)
// so vi.mock can intercept its library imports; NodeTestHarness loads from dist via
// require(), where vi.mock can't reach it.
const baseDescription: INodeTypeBaseDescription = {
displayName: 'Kafka',
name: 'kafka',
group: ['transform'],
description: 'Sends messages to a Kafka topic',
};
const {
kafkajsLoadCount,
mockProducerConnect,
mockProducerSendBatch,
mockProducerDisconnect,
mockProducerFactory,
mockRegistryEncode,
mockRegistryGetLatestSchemaId,
} = vi.hoisted(() => {
const kafkajsLoadCount = { value: 0 };
const mockProducerConnect = vi.fn(async () => {});
const mockProducerSendBatch = vi.fn(async () => [] as unknown[]);
const mockProducerDisconnect = vi.fn(async () => {});
const mockProducerFactory = vi.fn(() => ({
connect: mockProducerConnect,
sendBatch: mockProducerSendBatch,
disconnect: mockProducerDisconnect,
}));
const mockRegistryEncode = vi.fn(async (_id: number, input: unknown) =>
Buffer.from(JSON.stringify(input)),
);
const mockRegistryGetLatestSchemaId = vi.fn(async (eventName: string) => {
if (eventName === 'failing-event-name') {
throw new Error('Subject not found');
}
return 1;
});
return {
kafkajsLoadCount,
mockProducerConnect,
mockProducerSendBatch,
mockProducerDisconnect,
mockProducerFactory,
mockRegistryEncode,
mockRegistryGetLatestSchemaId,
};
});
vi.mock('@confluentinc/kafka-javascript', () => confluentKafkaModuleMock());
// Counts module loads, not property reads: `kafkajs` reaching v2 would do so through
// a static value import somewhere in its graph (e.g. the shared `utils.ts`), which
// resolves once at import time — so the counter is never reset between tests.
vi.mock('kafkajs', () => {
kafkajsLoadCount.value += 1;
return { logLevel: { NOTHING: 0, ERROR: 1, WARN: 2, INFO: 3, DEBUG: 4 } };
});
vi.mock('@kafkajs/confluent-schema-registry', () => ({
SchemaRegistry: vi.fn(function () {
return {
getLatestSchemaId: mockRegistryGetLatestSchemaId,
encode: mockRegistryEncode,
};
}),
}));
const defaultKafkaCredentials: IDataObject = {
brokers: 'localhost:9092',
clientId: 'test-client',
ssl: false,
authentication: false,
};
// A param value may be a `(index) => value` function, so the item index the node
// reads a per-item parameter at is actually observable.
type NodeParams = Record<string, unknown>;
const mockLoggerWarn = vi.fn();
function createExecuteFunctions(
params: NodeParams,
items: INodeExecutionData[],
options: {
schemaRegistryCredential?: IDataObject;
continueOnFail?: boolean;
} = {},
) {
const { schemaRegistryCredential, continueOnFail = false } = options;
const node = mock<INode>({
name: 'Kafka',
credentials: schemaRegistryCredential
? { schemaRegistryApi: { id: 'wW0eW1iZK9d3Yz2g', name: 'Schema Registry account' } }
: undefined,
});
return mock<IExecuteFunctions>({
getInputData: () => items,
getNode: () => node,
logger: mock<IExecuteFunctions['logger']>({ warn: mockLoggerWarn }),
getNodeParameter: ((name: string, index: number, fallback?: unknown) => {
if (!(name in params)) return fallback;
const value = params[name];
return typeof value === 'function' ? (value as (i: number) => unknown)(index) : value;
}) as IExecuteFunctions['getNodeParameter'],
getCredentials: (async (type: string) =>
type === 'schemaRegistryApi'
? schemaRegistryCredential
: defaultKafkaCredentials) as IExecuteFunctions['getCredentials'],
continueOnFail: () => continueOnFail,
helpers: {
returnJsonArray: (data: IDataObject | IDataObject[]) =>
(Array.isArray(data) ? data : [data]).map((json) => ({ json })),
constructExecutionMetaData: (data: INodeExecutionData[]) => data,
} as unknown as IExecuteFunctions['helpers'],
});
}
const schemaRegistryCredential = {
url: 'https://cred-kafka-registry.local',
authentication: 'basicAuth',
username: 'registry-user',
password: 'registry-password',
};
describe('KafkaV2 Node', () => {
beforeEach(async () => {
vi.clearAllMocks();
// The shared fake's own `Kafka` mock returns a fresh, uninstrumented stub producer
// per instance — fine for the transport-level tests, but this file needs the same
// controllable producer across calls, so the constructor implementation is
// overridden (still through the shared fake's `vi.mock`, so lazy-loading semantics
// stay real) to return `mockProducerFactory`'s producer instead.
const { Kafka } = await getKafkaLibrary();
vi.mocked(Kafka).mockImplementation(function (config?: unknown) {
return {
config,
connect: vi.fn(),
disconnect: vi.fn(),
producer: mockProducerFactory,
consumer: vi.fn(),
admin: vi.fn(),
};
});
});
test('never loads the v1 kafkajs library', async () => {
const params: IDataObject = {
options: {},
sendInputData: true,
useSchemaRegistry: false,
topic: 'test-topic',
jsonParameters: false,
useKey: false,
headersUi: {},
};
const items: INodeExecutionData[] = [{ json: { name: 'item' } }];
await new KafkaV2(baseDescription).execute.call(createExecuteFunctions(params, items));
expect(kafkajsLoadCount.value).toBe(0);
});
test('publishes input data as messages with key and headers unchanged, acks/timeout on the producer config', async () => {
const params: IDataObject = {
options: { acks: true, timeout: 1000 },
sendInputData: true,
useSchemaRegistry: false,
topic: 'test-topic',
jsonParameters: false,
useKey: true,
key: 'messageKey',
headersUi: { headerValues: [{ key: 'header', value: 'value' }] },
};
const items: INodeExecutionData[] = [
{ json: { name: 'First item', code: 1 } },
{ json: { name: 'Second item', code: 2 } },
];
await new KafkaV2(baseDescription).execute.call(createExecuteFunctions(params, items));
expect(mockProducerFactory).toHaveBeenCalledWith({
kafkaJS: { acks: -1, timeout: 1000, allowAutoTopicCreation: true },
});
expect(mockProducerConnect).toHaveBeenCalledTimes(1);
expect(mockProducerSendBatch).toHaveBeenCalledTimes(1);
expect(mockProducerSendBatch).toHaveBeenCalledWith({
topicMessages: [
{
messages: [
{
headers: { header: 'value' },
key: 'messageKey',
value: '{"name":"First item","code":1}',
},
],
topic: 'test-topic',
},
{
messages: [
{
headers: { header: 'value' },
key: 'messageKey',
value: '{"name":"Second item","code":2}',
},
],
topic: 'test-topic',
},
],
});
expect(mockProducerDisconnect).toHaveBeenCalledTimes(1);
});
test('maps acks off to 0 and falls back to the default timeout when the option is unset', async () => {
const params: IDataObject = {
options: {},
sendInputData: false,
useSchemaRegistry: false,
topic: 'test-topic',
jsonParameters: false,
useKey: false,
message: 'plain message',
headersUi: {},
};
const items: INodeExecutionData[] = [{ json: {} }];
await new KafkaV2(baseDescription).execute.call(createExecuteFunctions(params, items));
expect(mockProducerFactory).toHaveBeenCalledWith({
kafkaJS: { acks: 0, timeout: 30000, allowAutoTopicCreation: true },
});
expect(mockProducerSendBatch).toHaveBeenCalledWith({
topicMessages: [
{
messages: [{ headers: {}, key: null, value: 'plain message' }],
topic: 'test-topic',
},
],
});
});
test('reads the topic and key per item', async () => {
const params: NodeParams = {
options: {},
sendInputData: true,
useSchemaRegistry: false,
topic: (i: number) => `topic-${i}`,
jsonParameters: false,
useKey: true,
key: (i: number) => `key-${i}`,
headersUi: {},
};
const items: INodeExecutionData[] = [{ json: { a: 1 } }, { json: { a: 2 } }];
await new KafkaV2(baseDescription).execute.call(createExecuteFunctions(params, items));
expect(mockProducerSendBatch).toHaveBeenCalledWith({
topicMessages: [
{ messages: [{ headers: {}, key: 'key-0', value: '{"a":1}' }], topic: 'topic-0' },
{ messages: [{ headers: {}, key: 'key-1', value: '{"a":2}' }], topic: 'topic-1' },
],
});
});
test('reports success when the broker returns no record metadata', async () => {
const params: NodeParams = {
options: {},
sendInputData: true,
useSchemaRegistry: false,
topic: 'test-topic',
jsonParameters: false,
useKey: false,
headersUi: {},
};
const items: INodeExecutionData[] = [{ json: { name: 'item' } }];
const result = await new KafkaV2(baseDescription).execute.call(
createExecuteFunctions(params, items),
);
expect(result).toEqual([[{ json: { success: true } }]]);
});
test('returns the broker record metadata as item data', async () => {
mockProducerSendBatch.mockResolvedValueOnce([{ topicName: 't', partition: 0, offset: '1' }]);
const params: NodeParams = {
options: {},
sendInputData: true,
useSchemaRegistry: false,
topic: 't',
jsonParameters: false,
useKey: false,
headersUi: {},
};
const items: INodeExecutionData[] = [{ json: { name: 'item' } }];
const result = await new KafkaV2(baseDescription).execute.call(
createExecuteFunctions(params, items),
);
expect(result).toEqual([[{ json: { topicName: 't', partition: 0, offset: '1' } }]]);
});
test('publishes a schema-registry-encoded message as the encoded bytes', async () => {
const params: IDataObject = {
options: {},
sendInputData: false,
useSchemaRegistry: true,
message: JSON.stringify({ foo: 'bar' }),
schemaRegistryUrl: 'https://test-kafka-registry.local',
eventName: 'test-event-name',
topic: 'test-topic',
jsonParameters: true,
useKey: false,
headerParametersJson: '{\n "headerKey": "headerValue"\n}',
};
const items: INodeExecutionData[] = [{ json: { success: true } }];
await new KafkaV2(baseDescription).execute.call(createExecuteFunctions(params, items));
expect(SchemaRegistry).toHaveBeenCalledWith({ host: 'https://test-kafka-registry.local' });
expect(mockRegistryGetLatestSchemaId).toHaveBeenCalledWith('test-event-name');
expect(mockRegistryEncode).toHaveBeenCalledWith(1, { foo: 'bar' });
expect(mockProducerSendBatch).toHaveBeenCalledWith({
topicMessages: [
{
messages: [
{
headers: { headerKey: 'headerValue' },
key: null,
value: Buffer.from(JSON.stringify({ foo: 'bar' })),
},
],
topic: 'test-topic',
},
],
});
});
const sendParams: NodeParams = {
options: {},
sendInputData: false,
useSchemaRegistry: false,
message: 'plain message',
topic: 'test-topic',
jsonParameters: false,
useKey: false,
headersUi: {},
};
test('disconnects the producer even when sendBatch rejects', async () => {
mockProducerSendBatch.mockRejectedValueOnce(new Error('broker unreachable'));
await expect(
new KafkaV2(baseDescription).execute.call(createExecuteFunctions(sendParams, [{ json: {} }])),
).rejects.toThrow('broker unreachable');
expect(mockProducerDisconnect).toHaveBeenCalledTimes(1);
});
test('surfaces the send error, not the disconnect error, when both reject', async () => {
mockProducerSendBatch.mockRejectedValueOnce(new Error('broker unreachable'));
mockProducerDisconnect.mockRejectedValueOnce(new Error('disconnect failed'));
await expect(
new KafkaV2(baseDescription).execute.call(createExecuteFunctions(sendParams, [{ json: {} }])),
).rejects.toThrow('broker unreachable');
// Swallowed, but not silently: a native client that fails to disconnect leaks threads.
expect(mockLoggerWarn).toHaveBeenCalledWith('Kafka producer failed to disconnect', {
error: 'disconnect failed',
});
});
test('still returns the send result when only the disconnect rejects', async () => {
mockProducerSendBatch.mockResolvedValueOnce([{ topicName: 'test-topic', partition: 0 }]);
mockProducerDisconnect.mockRejectedValueOnce(new Error('disconnect failed'));
const result = await new KafkaV2(baseDescription).execute.call(
createExecuteFunctions(sendParams, [{ json: {} }]),
);
// The message was accepted by the broker, so a failed cleanup must not fail the item.
expect(result).toEqual([[{ json: { topicName: 'test-topic', partition: 0 } }]]);
});
test('rejects a non-string header value before the producer is created', async () => {
const params: NodeParams = {
...sendParams,
jsonParameters: true,
headerParametersJson: '{"retries":3}',
};
await expect(
new KafkaV2(baseDescription).execute.call(createExecuteFunctions(params, [{ json: {} }])),
).rejects.toThrow('Header "retries" must be a string');
expect(mockProducerFactory).not.toHaveBeenCalled();
expect(mockProducerSendBatch).not.toHaveBeenCalled();
});
test('attributes malformed JSON headers to the failing item', async () => {
const params: NodeParams = {
...sendParams,
jsonParameters: true,
headerParametersJson: (i: number) => (i === 1 ? 'not json' : '{"ok":"yes"}'),
};
const error = await new KafkaV2(baseDescription).execute
.call(createExecuteFunctions(params, [{ json: {} }, { json: {} }]))
.catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(NodeOperationError);
expect((error as NodeOperationError).message).toBe('Headers must be valid JSON');
expect((error as NodeOperationError).context.itemIndex).toBe(1);
});
test('disconnects and propagates the connect error when connect rejects', async () => {
mockProducerConnect.mockRejectedValueOnce(new Error('connection refused'));
await expect(
new KafkaV2(baseDescription).execute.call(createExecuteFunctions(sendParams, [{ json: {} }])),
).rejects.toThrow('connection refused');
expect(mockProducerSendBatch).not.toHaveBeenCalled();
expect(mockProducerDisconnect).toHaveBeenCalledTimes(1);
});
test('returns the send error as item data when the node continues on fail', async () => {
mockProducerSendBatch.mockRejectedValueOnce(new Error('broker unreachable'));
const result = await new KafkaV2(baseDescription).execute.call(
createExecuteFunctions(sendParams, [{ json: {} }], { continueOnFail: true }),
);
expect(result).toEqual([
[{ json: { error: 'broker unreachable' }, pairedItem: [{ item: 0 }] }],
]);
expect(mockProducerDisconnect).toHaveBeenCalledTimes(1);
});
test('resolves the schema once and encodes every item', async () => {
const params: NodeParams = {
options: {},
sendInputData: true,
useSchemaRegistry: true,
schemaRegistryUrl: 'https://test-kafka-registry.local',
eventName: 'test-event-name',
topic: 'test-topic',
jsonParameters: false,
useKey: false,
headersUi: {},
};
const items: INodeExecutionData[] = [{ json: { a: 1 } }, { json: { a: 2 } }];
await new KafkaV2(baseDescription).execute.call(createExecuteFunctions(params, items));
expect(mockRegistryGetLatestSchemaId).toHaveBeenCalledTimes(1);
expect(mockRegistryEncode).toHaveBeenCalledTimes(2);
expect(mockRegistryEncode).toHaveBeenNthCalledWith(1, 1, { a: 1 });
expect(mockRegistryEncode).toHaveBeenNthCalledWith(2, 1, { a: 2 });
});
test('fails before the producer is built when the message is not valid JSON', async () => {
const params: NodeParams = {
options: {},
sendInputData: false,
useSchemaRegistry: true,
message: 'not-json',
schemaRegistryUrl: 'https://test-kafka-registry.local',
eventName: 'test-event-name',
topic: 'test-topic',
};
await expect(
new KafkaV2(baseDescription).execute.call(createExecuteFunctions(params, [{ json: {} }])),
).rejects.toThrow('Message is not valid JSON');
expect(mockProducerFactory).not.toHaveBeenCalled();
});
test('fails before the producer is built when the JSON headers are not valid JSON', async () => {
const params: NodeParams = {
options: {},
sendInputData: false,
useSchemaRegistry: false,
message: 'plain message',
topic: 'test-topic',
jsonParameters: true,
useKey: false,
headerParametersJson: 'not-json',
};
await expect(
new KafkaV2(baseDescription).execute.call(createExecuteFunctions(params, [{ json: {} }])),
).rejects.toThrow('Headers must be valid JSON');
expect(mockProducerFactory).not.toHaveBeenCalled();
});
test('should configure the schema registry from the selected credential', async () => {
const params: IDataObject = {
options: {},
sendInputData: false,
useSchemaRegistry: true,
message: JSON.stringify({ foo: 'bar' }),
schemaRegistryUrl: '',
eventName: 'test-event-name',
topic: 'cred-test-topic',
jsonParameters: true,
useKey: false,
headerParametersJson: '{\n "headerKey": "headerValue"\n}',
};
const items: INodeExecutionData[] = [{ json: { success: true } }];
await new KafkaV2(baseDescription).execute.call(
createExecuteFunctions(params, items, { schemaRegistryCredential }),
);
expect(SchemaRegistry).toHaveBeenCalledWith({
host: 'https://cred-kafka-registry.local',
auth: { username: 'registry-user', password: 'registry-password' },
});
});
test('should fail with the generic message when the schema lookup fails', async () => {
const params: IDataObject = {
options: {},
sendInputData: false,
useSchemaRegistry: true,
message: '{"foo":"bar"}',
schemaRegistryUrl: '',
eventName: 'failing-event-name',
topic: 'error-test-topic',
};
const items: INodeExecutionData[] = [{ json: {} }];
await expect(
new KafkaV2(baseDescription).execute.call(
createExecuteFunctions(params, items, { schemaRegistryCredential }),
),
).rejects.toThrow('Verify your Schema Registry configuration');
expect(mockProducerFactory).not.toHaveBeenCalled();
});
test('should return the error as item data when the node continues on fail', async () => {
const params: IDataObject = {
options: {},
sendInputData: false,
useSchemaRegistry: true,
message: '{"foo":"bar"}',
schemaRegistryUrl: '',
eventName: 'test-event-name',
topic: 'error-test-topic',
};
const items: INodeExecutionData[] = [{ json: {} }];
const result = await new KafkaV2(baseDescription).execute.call(
createExecuteFunctions(params, items, {
schemaRegistryCredential: { ...schemaRegistryCredential, password: '' },
continueOnFail: true,
}),
);
expect(result).toEqual([
[
expect.objectContaining({
json: { error: 'Username and password are required for Schema Registry Basic Auth' },
}),
],
]);
});
});
@@ -0,0 +1,113 @@
import type { KafkaJS } from '@confluentinc/kafka-javascript';
import type { KafkaCredentials } from '../../../utils';
import { getKafkaLibrary } from '../../../v2/transport/client';
import { createKafkaProducer } from '../../../v2/transport/producer';
import { confluentKafkaModuleMock } from '../../mocks/confluent-kafka';
vi.mock('@confluentinc/kafka-javascript', () => confluentKafkaModuleMock());
const CA_PEM = '-----BEGIN CERTIFICATE-----\nMIIBcacertbody==\n-----END CERTIFICATE-----';
const credentials: KafkaCredentials = {
clientId: 'test',
brokers: 'localhost:9092',
ssl: false,
authentication: false,
};
// client.ts caches the library after the first call in this file, so re-reading it
// here always returns the same `Kafka` mock constructor createKafkaProducer used.
async function kafkaConstructorMock() {
const { Kafka } = await getKafkaLibrary();
return vi.mocked(Kafka);
}
async function lastKafkaInstance() {
return (await kafkaConstructorMock()).mock.results.at(-1)?.value;
}
describe('createKafkaProducer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.each([
{ acks: 1, timeout: 30000 },
{ acks: 0, timeout: 5000 },
])('passes acks and timeout through under the kafkaJS key (%o)', async (options) => {
await createKafkaProducer(credentials, options);
const kafkaInstance = await lastKafkaInstance();
expect(kafkaInstance.producer).toHaveBeenCalledWith({
kafkaJS: { ...options, allowAutoTopicCreation: true },
});
});
it('applies the optional compression codec to the producer config', async () => {
await createKafkaProducer(credentials, {
acks: 1,
timeout: 30000,
compression: 'gzip' as KafkaJS.CompressionTypes,
});
const kafkaInstance = await lastKafkaInstance();
expect(kafkaInstance.producer).toHaveBeenCalledWith({
kafkaJS: { acks: 1, timeout: 30000, allowAutoTopicCreation: true, compression: 'gzip' },
});
});
it('builds the client from the credential', async () => {
await createKafkaProducer(credentials, { acks: 1, timeout: 30000 });
expect(await kafkaConstructorMock()).toHaveBeenCalledWith(
expect.objectContaining({
kafkaJS: expect.objectContaining({ brokers: ['localhost:9092'], clientId: 'test' }),
}),
);
});
it('hands the sasl and TLS material of the credential to the client', async () => {
await createKafkaProducer(
{
...credentials,
ssl: true,
ca: CA_PEM,
authentication: true,
saslMechanism: 'scram-sha-512',
username: 'user',
password: 'pass',
},
{ acks: 1, timeout: 30000 },
);
expect(await kafkaConstructorMock()).toHaveBeenCalledWith(
expect.objectContaining({
kafkaJS: expect.objectContaining({
ssl: true,
sasl: { mechanism: 'scram-sha-512', username: 'user', password: 'pass' },
}),
'ssl.ca.pem': CA_PEM,
}),
);
});
it('pins the library log level so it does not write broker details to stdout', async () => {
await createKafkaProducer(credentials, { acks: 1, timeout: 30000 });
const { logLevel } = await getKafkaLibrary();
expect(await kafkaConstructorMock()).toHaveBeenCalledWith(
expect.objectContaining({
kafkaJS: expect.objectContaining({ logLevel: logLevel.ERROR }),
}),
);
});
it('returns the client-built producer, unconnected', async () => {
const producer = await createKafkaProducer(credentials, { acks: 1, timeout: 30000 });
const kafkaInstance = await lastKafkaInstance();
expect(producer).toBe(kafkaInstance.producer.mock.results[0].value);
expect(producer.connect).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,436 @@
import type { KafkaJS } from '@confluentinc/kafka-javascript';
import type { SchemaRegistry } from '@kafkajs/confluent-schema-registry';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import type {
IExecuteFunctions,
IDataObject,
INode,
INodeExecutionData,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import { jsonParse, NodeConnectionTypes, NodeError, NodeOperationError } from 'n8n-workflow';
import { generatePairedItemData } from '@utils/utilities';
import { createSchemaRegistry, type KafkaCredentials } from '../utils';
import { createKafkaProducer, type KafkaProducerOptions } from './transport';
const DEFAULT_TIMEOUT_MS = 30000;
/** One row of the `headersUi` fixed collection. */
interface HeaderRow {
key: string;
value: string;
}
/**
* Maps the `options` collection onto the producer factory's options. Both fields
* differ deliberately from v1, so they are converted in one place rather than at
* the call site.
*/
function toProducerOptions(options: IDataObject): KafkaProducerOptions {
return {
// -1 = all in-sync replicas, matching the option description. v1 maps
// `true` to 1 (leader only) — a bug not worth carrying into a new version.
acks: options.acks === true ? -1 : 0,
// Unlike v1 (kafkajs tolerates `undefined`), confluent's native library
// crashes if `timeout` reaches the producer config as `undefined` — which
// it would be here if the user never added the option, since a `collection`
// param only carries keys the user explicitly set, ignoring its declared
// UI default. Fall back to that same default explicitly.
timeout: (options.timeout as number | undefined) ?? DEFAULT_TIMEOUT_MS,
};
}
/** Resolved Schema Registry client plus the schema id every message encodes against. */
interface ResolvedSchemaRegistry {
registry: SchemaRegistry;
schemaId: number;
}
/**
* Encodes a message for the wire, returning it unchanged when the Schema Registry
* is off. Both failure modes are the user's to fix, so each maps to its own
* message rather than surfacing a registry-internal error.
*/
async function encodeMessage(
message: string,
schemaRegistry: ResolvedSchemaRegistry | undefined,
node: INode,
itemIndex: number,
): Promise<string | Buffer> {
if (!schemaRegistry) return message;
let parsedMessage: unknown;
try {
parsedMessage = JSON.parse(message);
} catch {
throw new NodeOperationError(node, 'Message is not valid JSON', {
description:
'The Schema Registry encodes JSON messages. Provide a valid JSON message, or turn off "Use Schema Registry".',
itemIndex,
});
}
try {
return await schemaRegistry.registry.encode(schemaRegistry.schemaId, parsedMessage);
} catch {
// The original error is dropped rather than kept as `cause`: registry errors
// interpolate the request URL and response body, which would then be
// persisted into execution data.
throw new NodeOperationError(node, 'Verify your Schema Registry configuration', { itemIndex });
}
}
/**
* The native binding rejects a non-string header value only after the message is queued,
* so an unchecked value fails the node for a message the broker already accepted.
*/
function validateHeaders(headers: KafkaJS.IHeaders, node: INode, itemIndex: number) {
for (const [key, value] of Object.entries(headers)) {
const values = Array.isArray(value) ? value : [value];
if (values.some((entry) => typeof entry !== 'string' && !Buffer.isBuffer(entry))) {
throw new NodeOperationError(node, `Header "${key}" must be a string`, { itemIndex });
}
}
}
const versionDescription: INodeTypeDescription = {
displayName: 'Kafka',
name: 'kafka',
icon: { light: 'file:kafka.svg', dark: 'file:kafka.dark.svg' },
group: ['transform'],
version: 2,
description: 'Sends messages to a Kafka topic',
defaults: {
name: 'Kafka',
},
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [
{
// Leave the `kafka` credential test to v1: it is resolved per credential type, so a
// `methods.credentialTest.kafkaConnectionTest` here would take over v1's test too.
// See 'should leave the kafka credential test to v1' in test/Kafka.node.test.ts.
name: 'kafka',
required: true,
},
{
name: 'schemaRegistryApi',
required: false,
displayName: 'Schema Registry',
displayOptions: {
show: {
useSchemaRegistry: [true],
},
},
},
],
properties: [
{
displayName: 'Topic',
name: 'topic',
type: 'string',
default: '',
placeholder: 'topic-name',
description: 'Name of the queue of topic to publish to',
},
{
displayName: 'Send Input Data',
name: 'sendInputData',
type: 'boolean',
default: true,
description: 'Whether to send the data the node receives as JSON to Kafka',
},
{
displayName: 'Message',
name: 'message',
type: 'string',
displayOptions: {
show: {
sendInputData: [false],
},
},
default: '',
description: 'The message to be sent',
},
{
displayName: 'JSON Parameters',
name: 'jsonParameters',
type: 'boolean',
default: false,
},
{
displayName: 'Use Schema Registry',
name: 'useSchemaRegistry',
type: 'boolean',
default: false,
description: 'Whether to use Confluent Schema Registry',
},
{
displayName: 'Schema Registry URL',
name: 'schemaRegistryUrl',
type: 'string',
displayOptions: {
show: {
useSchemaRegistry: [true],
},
},
placeholder: 'https://schema-registry-domain:8081',
default: '',
description:
'URL of the schema registry. Only used when no Schema Registry credential is selected.',
},
{
displayName: 'Use Key',
name: 'useKey',
type: 'boolean',
default: false,
description: 'Whether to use a message key',
},
{
displayName: 'Key',
name: 'key',
type: 'string',
required: true,
displayOptions: {
show: {
useKey: [true],
},
},
placeholder: '',
default: '',
description: 'The message key',
},
{
displayName: 'Event Name',
name: 'eventName',
type: 'string',
required: true,
displayOptions: {
show: {
useSchemaRegistry: [true],
},
},
default: '',
description: 'Namespace and Name of Schema in Schema Registry (namespace.name)',
},
{
displayName: 'Headers',
name: 'headersUi',
placeholder: 'Add Header',
type: 'fixedCollection',
displayOptions: {
show: {
jsonParameters: [false],
},
},
typeOptions: {
multipleValues: true,
},
default: {},
options: [
{
name: 'headerValues',
displayName: 'Header',
values: [
{
displayName: 'Key',
name: 'key',
type: 'string',
default: '',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
},
],
},
],
},
{
displayName: 'Headers (JSON)',
name: 'headerParametersJson',
type: 'json',
displayOptions: {
show: {
jsonParameters: [true],
},
},
default: '',
description: 'Header parameters as JSON (flat object)',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
placeholder: 'Add option',
options: [
{
displayName: 'Acks',
name: 'acks',
type: 'boolean',
default: false,
description: 'Whether or not producer must wait for acknowledgement from all replicas',
},
{
displayName: 'Timeout',
name: 'timeout',
type: 'number',
default: DEFAULT_TIMEOUT_MS,
description: 'The time to await a response in ms',
},
],
},
],
};
export class KafkaV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
...versionDescription,
};
}
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const itemData = generatePairedItemData(items.length);
const length = items.length;
const topicMessages: KafkaJS.TopicMessages[] = [];
let responseData: IDataObject[];
try {
const producerOptions = toProducerOptions(this.getNodeParameter('options', 0));
const sendInputData = this.getNodeParameter('sendInputData', 0) as boolean;
const useSchemaRegistry = this.getNodeParameter('useSchemaRegistry', 0) as boolean;
const credentials = await this.getCredentials<KafkaCredentials>('kafka');
// Resolve the registry configuration once, before the producer is set
// up, so credential misconfiguration surfaces with its own error
// message and never leaks a connected producer. The registry client
// and schema ID are loop-invariant (`eventName` is read at index 0)
let schemaRegistry: ResolvedSchemaRegistry | undefined;
if (useSchemaRegistry) {
const registry = await createSchemaRegistry(
this,
this.getNodeParameter('schemaRegistryUrl', 0) as string,
);
try {
const eventName = this.getNodeParameter('eventName', 0) as string;
const schemaId = await registry.getLatestSchemaId(eventName);
schemaRegistry = { registry, schemaId };
} catch (exception) {
throw new NodeOperationError(this.getNode(), 'Verify your Schema Registry configuration');
}
}
for (let i = 0; i < length; i++) {
const rawMessage = sendInputData
? JSON.stringify(items[i].json)
: (this.getNodeParameter('message', i) as string);
const message = await encodeMessage(rawMessage, schemaRegistry, this.getNode(), i);
const topic = this.getNodeParameter('topic', i) as string;
const jsonParameters = this.getNodeParameter('jsonParameters', i);
const useKey = this.getNodeParameter('useKey', i) as boolean;
const key = useKey ? (this.getNodeParameter('key', i) as string) : null;
let headers: KafkaJS.IHeaders;
if (jsonParameters) {
try {
headers = jsonParse<KafkaJS.IHeaders>(
this.getNodeParameter('headerParametersJson', i) as string,
);
} catch {
throw new NodeOperationError(this.getNode(), 'Headers must be valid JSON', {
itemIndex: i,
});
}
} else {
// `Object.fromEntries` builds the object in one step rather than assigning
// user-supplied names as computed keys.
headers = Object.fromEntries(
(
((this.getNodeParameter('headersUi', i) as IDataObject).headerValues ??
[]) as HeaderRow[]
).map(({ key: headerKey, value }) => [headerKey, value]),
);
}
validateHeaders(headers, this.getNode(), i);
topicMessages.push({
topic,
messages: [
{
value: message,
headers,
key,
},
],
});
}
const producer = await createKafkaProducer(credentials, producerOptions);
try {
await producer.connect();
responseData = await producer.sendBatch({ topicMessages });
} finally {
// Unlike v1, always close the connection. The failure is logged rather than
// rethrown so it can never mask the error the user needs to see — but a native
// client that fails to disconnect leaks threads, so it must leave a trace.
await producer.disconnect().catch((disconnectError) => {
this.logger.warn('Kafka producer failed to disconnect', {
error: ensureError(disconnectError).message,
});
});
}
if (responseData.length === 0) {
responseData.push({
success: true,
});
}
const executionData = this.helpers.constructExecutionMetaData(
this.helpers.returnJsonArray(responseData),
{ itemData },
);
return [executionData];
} catch (error) {
if (this.continueOnFail()) {
return [[{ json: { error: ensureError(error).message }, pairedItem: itemData }]];
}
// The transport throws plain UserErrors for an unusable credential, and core adds no
// node context to non-NodeErrors, so they would surface in the UI unattributed.
if (error instanceof NodeError) throw error;
throw new NodeOperationError(this.getNode(), ensureError(error));
}
}
}
@@ -1,2 +1 @@
export { toKafkaJSConfig } from './config';
export { getKafkaLibrary } from './client';
export { createKafkaProducer, type KafkaProducerOptions } from './producer';
@@ -0,0 +1,34 @@
import type { KafkaJS } from '@confluentinc/kafka-javascript';
import { getKafkaLibrary } from './client';
import { toKafkaJSConfig } from './config';
import type { KafkaCredentials } from '../../utils';
export interface KafkaProducerOptions {
acks: number;
timeout: number;
// Must be set at construction: the library locks compression in when the producer is created.
compression?: KafkaJS.CompressionTypes;
}
export async function createKafkaProducer(
credentials: KafkaCredentials,
options: KafkaProducerOptions,
): Promise<KafkaJS.Producer> {
const { Kafka, logLevel } = await getKafkaLibrary();
const config = toKafkaJSConfig(credentials);
// Without an explicit level the library's own logger writes broker host:port to
// process stdout on every execution, outside n8n's logger.
const kafka = new Kafka({ ...config, kafkaJS: { ...config.kafkaJS, logLevel: logLevel.ERROR } });
// acks and timeout are locked in at construction: the library's KafkaJS
// compatibility layer ignores them when passed to sendBatch.
return kafka.producer({
kafkaJS: {
acks: options.acks,
timeout: options.timeout,
allowAutoTopicCreation: true,
...(options.compression && { compression: options.compression }),
},
});
}