@eventferry/kafka 3.3.1 → 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/CHANGELOG.md +15 -0
- package/README.md +184 -0
- package/dist/consume.cjs +115 -0
- package/dist/consume.cjs.map +1 -0
- package/dist/consume.d.cts +114 -0
- package/dist/consume.d.ts +114 -0
- package/dist/consume.js +88 -0
- package/dist/consume.js.map +1 -0
- package/dist/index.cjs +269 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +218 -1
- package/dist/index.d.ts +218 -1
- package/dist/index.js +269 -7
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @eventferry/kafka
|
|
2
2
|
|
|
3
|
+
## 3.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3e0c5ee: Add typed admin surface to `KafkaPublisher`: `publisher.admin()` borrows a connected admin client (caller closes it), `publisher.ensureTopics()` idempotently provisions topics with an optional `growPartitions` flag, and a new `validateTopicsOnConnect` option fails fast at startup when expected topics are missing. Implemented on both the kafkajs and confluent drivers; custom drivers that don't implement the optional `admin()` method get a clear error message instead of a silent surprise.
|
|
8
|
+
- ac7a964: Add consumer-side helpers via the new `@eventferry/kafka/consume` subpath import: `decode(message, { decoder })` normalizes the raw message shape (key, value, headers, offset, timestamp, partition) both kafkajs and confluent deliver — with built-in `json` / `utf8` / `none` decoders plus a custom-function escape hatch; `extractTraceContext(headers)` parses the W3C `traceparent` / `tracestate` headers (strict validation per the W3C Trace Context spec) and accepts both raw (Buffer) and decoded (string) header shapes. Paired on the producer side with a new optional `KafkaTracer.inject(span, headers)` hook so OpenTelemetry users can complete the publish→consume trace propagation in two lines. The publisher clones each message before invoking `inject` — the caller's `PublishableMessage` references are never mutated, keeping the relay's retry path safe.
|
|
9
|
+
- 4007a8e: Power-user escape hatches for both drivers. The high-level options cover ~95% of cases; these let you reach into the native client when you need a knob we don't expose typed.
|
|
10
|
+
|
|
11
|
+
- `compressionLevel`: per-codec level (confluent only, e.g. `zstd` level 1-22). Maps to librdkafka's `compression.level`. The kafkajs driver warns once and ignores it (kafkajs has no codec-level config).
|
|
12
|
+
- `rawProducerConfig`: raw librdkafka keys merged into the confluent producer config. Native keys **win** against eventferry's translated ones — use this to override defaults or to tune surface area (queue buffering, statistics interval, socket keepalive, …) we don't expose.
|
|
13
|
+
- `rawKafkaJsProducerConfig`: same idea for kafkajs — raw keys merged into `kafka.producer({...})` with last-write-wins precedence.
|
|
14
|
+
- `customPartitioner`: kafkajs partitioner factory (`() => (args) => number`). Overrides the `partitioner` preset entirely. Confluent ignores it — librdkafka's partitioner is a C-level extension point.
|
|
15
|
+
|
|
16
|
+
Native config takes precedence over eventferry's translated keys in every case — that's the contract of an escape hatch.
|
|
17
|
+
|
|
3
18
|
## 3.3.1
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -271,6 +271,89 @@ Per the spec, eventferry emits **one span per `publish()` call**, named `"{topic
|
|
|
271
271
|
|
|
272
272
|
The user-supplied tracer SHOULD set `SpanKind.PRODUCER` on the span; the adapter above does this explicitly.
|
|
273
273
|
|
|
274
|
+
#### Propagating trace context to consumers
|
|
275
|
+
|
|
276
|
+
Add an optional `inject` method on the tracer to write the W3C `traceparent` / `tracestate` headers into each outgoing message. Pair this with `extractTraceContext` on the consumer side (see [Consumer helpers](#consumer-helpers--eventferrykafkaconsume)).
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { context as otelContext, propagation, trace } from "@opentelemetry/api";
|
|
280
|
+
|
|
281
|
+
const tracer: KafkaTracer = {
|
|
282
|
+
startPublishSpan: /* …as above… */,
|
|
283
|
+
inject(_span, headers) {
|
|
284
|
+
// The publisher wraps the active span context for us before calling this.
|
|
285
|
+
propagation.inject(otelContext.active(), headers);
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The publisher clones each outbound message before injecting (the caller's `PublishableMessage` is never mutated, so the relay's retry path stays correct).
|
|
291
|
+
|
|
292
|
+
## Power-user escape hatches
|
|
293
|
+
|
|
294
|
+
When the high-level options don't reach a knob you need, drop down to the native client config.
|
|
295
|
+
|
|
296
|
+
### Compression level
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
new KafkaPublisher({
|
|
300
|
+
brokers,
|
|
301
|
+
driver: "confluent",
|
|
302
|
+
compression: "zstd",
|
|
303
|
+
compressionLevel: 9, // librdkafka compression.level
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Confluent only. The kafkajs driver logs a one-time warning and ignores it (kafkajs does not expose codec levels). Default level is the codec's broker-friendly default.
|
|
308
|
+
|
|
309
|
+
### Raw librdkafka producer config (confluent driver)
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
new KafkaPublisher({
|
|
313
|
+
brokers,
|
|
314
|
+
driver: "confluent",
|
|
315
|
+
rawProducerConfig: {
|
|
316
|
+
"queue.buffering.max.messages": 100_000,
|
|
317
|
+
"statistics.interval.ms": 5_000,
|
|
318
|
+
"socket.keepalive.enable": true,
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Merged on TOP of eventferry's translated config — raw keys **win** against the translated ones. Use this to override defaults (set `linger.ms` directly) or to tune surface area we don't expose (`queue.buffering.max.kbytes`, etc.).
|
|
324
|
+
|
|
325
|
+
### Raw kafkajs producer config (kafkajs driver)
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
new KafkaPublisher({
|
|
329
|
+
brokers,
|
|
330
|
+
driver: "kafkajs",
|
|
331
|
+
rawKafkaJsProducerConfig: {
|
|
332
|
+
retry: { retries: 7, initialRetryTime: 250 },
|
|
333
|
+
metadataMaxAge: 5_000,
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Same precedence — raw keys win. Use for kafkajs-internal knobs (`retry`, `metadataMaxAge`) or to override defaults like `idempotent`.
|
|
339
|
+
|
|
340
|
+
### Custom partitioner (kafkajs driver)
|
|
341
|
+
|
|
342
|
+
```ts
|
|
343
|
+
const tenantAwarePartitioner = () => ({ topic, partitionMetadata, message }) => {
|
|
344
|
+
const tenant = message.headers["x-tenant"]?.toString();
|
|
345
|
+
return hashToPartition(tenant, partitionMetadata.length);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
new KafkaPublisher({
|
|
349
|
+
brokers,
|
|
350
|
+
driver: "kafkajs",
|
|
351
|
+
customPartitioner: tenantAwarePartitioner,
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Overrides the `partitioner` preset. Confluent ignores this — librdkafka's partitioner is a C-level extension point, not a JS callback.
|
|
356
|
+
|
|
274
357
|
### Logger
|
|
275
358
|
|
|
276
359
|
Pass a `Logger` (the same interface used by `@eventferry/core`) to route the publisher's own diagnostics — driver warnings, hook failures — through your logging stack:
|
|
@@ -284,6 +367,107 @@ new KafkaPublisher({
|
|
|
284
367
|
|
|
285
368
|
When omitted, the publisher is silent and the driver falls back to `console.warn` for its diagnostics (preserves prior behavior).
|
|
286
369
|
|
|
370
|
+
## Admin operations
|
|
371
|
+
|
|
372
|
+
The publisher exposes a typed admin surface for listing/describing/creating topics — handy for provisioning in CI, integration tests, or app boot.
|
|
373
|
+
|
|
374
|
+
### `publisher.admin()`
|
|
375
|
+
|
|
376
|
+
Borrow a fresh admin client. The returned client is connected and ready; the **caller** is responsible for closing it.
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
const admin = await publisher.admin();
|
|
380
|
+
try {
|
|
381
|
+
const topics = await admin.listTopics();
|
|
382
|
+
const desc = await admin.describeTopics(["orders"]);
|
|
383
|
+
console.log(desc[0].partitions.length); // partition count
|
|
384
|
+
} finally {
|
|
385
|
+
await admin.close();
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Methods on the returned `KafkaAdmin`:
|
|
390
|
+
|
|
391
|
+
- `listTopics(): Promise<string[]>` — all topic names visible to this principal.
|
|
392
|
+
- `describeTopics(topics): Promise<TopicMetadata[]>` — partition / leader / ISR per topic. Missing topics come back with an empty `partitions` array (no try/catch needed to detect absence).
|
|
393
|
+
- `createTopics(specs)` — idempotent: existing topics are silently skipped.
|
|
394
|
+
- `createPartitions(specs)` — grow a topic's partition count (Kafka does not support shrinking).
|
|
395
|
+
- `close()` — disconnect.
|
|
396
|
+
|
|
397
|
+
### `publisher.ensureTopics()`
|
|
398
|
+
|
|
399
|
+
One-shot, idempotent provisioning built on top of the admin surface:
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
await publisher.ensureTopics([
|
|
403
|
+
{ topic: "orders", numPartitions: 12, replicationFactor: 3 },
|
|
404
|
+
{ topic: "orders.dlq", numPartitions: 3, replicationFactor: 3, configEntries: { "retention.ms": "604800000" } },
|
|
405
|
+
]);
|
|
406
|
+
|
|
407
|
+
// Optionally grow existing topics whose partition count is below the requested numPartitions:
|
|
408
|
+
await publisher.ensureTopics(
|
|
409
|
+
[{ topic: "orders", numPartitions: 24 }],
|
|
410
|
+
{ growPartitions: true },
|
|
411
|
+
);
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
What it does:
|
|
415
|
+
|
|
416
|
+
- Creates topics that don't exist.
|
|
417
|
+
- Skips topics that already exist (no error, no surprise alter).
|
|
418
|
+
- With `growPartitions: true`, calls `createPartitions` for existing topics whose current partition count is **below** the requested `numPartitions`.
|
|
419
|
+
|
|
420
|
+
What it does NOT do (by design):
|
|
421
|
+
|
|
422
|
+
- Reconcile replication factor on existing topics — Kafka has no safe in-place alter (use partition reassignment for that).
|
|
423
|
+
- Reconcile `configEntries` on existing topics — use `kafka-configs.sh` or the raw admin client (kafkajs's `alterConfigs`) if you need that.
|
|
424
|
+
|
|
425
|
+
### Consumer helpers — `@eventferry/kafka/consume`
|
|
426
|
+
|
|
427
|
+
eventferry is publisher-only, but the records it produces are consumed somewhere downstream. The `consume` subpath ships zero-dep helpers for the typical decode + trace-continuation glue, and pulls in no kafkajs/confluent code:
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
import { decode, extractTraceContext } from "@eventferry/kafka/consume";
|
|
431
|
+
|
|
432
|
+
await consumer.run({
|
|
433
|
+
eachMessage: async ({ message }) => {
|
|
434
|
+
// Normalize key/headers/value; default decoder is JSON.
|
|
435
|
+
const { key, value, headers, offset } = decode<{ orderId: string }>(message);
|
|
436
|
+
|
|
437
|
+
// Continue the producer's W3C trace context (if the publisher's tracer
|
|
438
|
+
// injects it — see "OpenTelemetry tracing" above for the inject hook).
|
|
439
|
+
const trace = extractTraceContext(message.headers);
|
|
440
|
+
if (trace) {
|
|
441
|
+
// → start a CONSUMER span as a child of trace.traceId / trace.spanId
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
await handle(value!);
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
`decode` options:
|
|
450
|
+
|
|
451
|
+
- `decoder: "json"` (default) — `JSON.parse(value.toString("utf8"))`. Empty/null value → `null` (handles compaction tombstones).
|
|
452
|
+
- `decoder: "utf8"` — raw text.
|
|
453
|
+
- `decoder: "none"` — raw `Buffer`.
|
|
454
|
+
- `decoder: (bytes) => …` — custom (Avro, Protobuf, MessagePack, …).
|
|
455
|
+
|
|
456
|
+
`extractTraceContext` returns `null` if no `traceparent` header is present or it fails W3C validation (all-zero IDs, `version: ff`, malformed hex). It accepts both raw consumer headers (Buffer values) and already-decoded headers (string values).
|
|
457
|
+
|
|
458
|
+
### `validateTopicsOnConnect`
|
|
459
|
+
|
|
460
|
+
Fail-fast at startup if expected topics are missing:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
new KafkaPublisher({
|
|
464
|
+
brokers,
|
|
465
|
+
validateTopicsOnConnect: ["orders", "orders.dlq", "events"],
|
|
466
|
+
});
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`connect()` opens an admin, runs `listTopics`, and throws a single descriptive error naming **every** missing topic. The admin is always closed (success or failure). Skip the check entirely with an empty list or by omitting the option.
|
|
470
|
+
|
|
287
471
|
📖 **Full documentation:** [github.com/SametGoktepe/eventferry](https://github.com/SametGoktepe/eventferry#readme)
|
|
288
472
|
|
|
289
473
|
## License
|
package/dist/consume.cjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/consume.ts
|
|
21
|
+
var consume_exports = {};
|
|
22
|
+
__export(consume_exports, {
|
|
23
|
+
decode: () => decode,
|
|
24
|
+
decodeHeaders: () => decodeHeaders,
|
|
25
|
+
extractTraceContext: () => extractTraceContext
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(consume_exports);
|
|
28
|
+
function decodeHeaders(raw) {
|
|
29
|
+
if (!raw) return {};
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
32
|
+
if (v === void 0 || v === null) continue;
|
|
33
|
+
out[k] = Buffer.isBuffer(v) ? v.toString("utf8") : v;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function decode(msg, opts = {}) {
|
|
38
|
+
const headers = decodeHeaders(msg.headers);
|
|
39
|
+
const key = normalizeKey(msg.key);
|
|
40
|
+
const value = decodeValue(msg.value, opts.decoder ?? "json");
|
|
41
|
+
const timestamp = msg.timestamp !== void 0 ? Number(msg.timestamp) : void 0;
|
|
42
|
+
const offset = msg.offset !== void 0 ? String(msg.offset) : void 0;
|
|
43
|
+
return {
|
|
44
|
+
key,
|
|
45
|
+
value,
|
|
46
|
+
headers,
|
|
47
|
+
timestamp,
|
|
48
|
+
offset,
|
|
49
|
+
partition: msg.partition
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function normalizeKey(key) {
|
|
53
|
+
if (key === null || key === void 0) return null;
|
|
54
|
+
return Buffer.isBuffer(key) ? key.toString("utf8") : key;
|
|
55
|
+
}
|
|
56
|
+
function decodeValue(value, decoder) {
|
|
57
|
+
if (value === null || value === void 0) return null;
|
|
58
|
+
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
59
|
+
if (buf.length === 0) return null;
|
|
60
|
+
if (typeof decoder === "function") return decoder(buf);
|
|
61
|
+
switch (decoder) {
|
|
62
|
+
case "utf8":
|
|
63
|
+
return buf.toString("utf8");
|
|
64
|
+
case "none":
|
|
65
|
+
return buf;
|
|
66
|
+
case "json":
|
|
67
|
+
case void 0:
|
|
68
|
+
default: {
|
|
69
|
+
const text = buf.toString("utf8");
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(text);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`decode: JSON.parse failed on message value: ${err.message}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
var TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
81
|
+
var INVALID_TRACE_ID = "0".repeat(32);
|
|
82
|
+
var INVALID_SPAN_ID = "0".repeat(16);
|
|
83
|
+
function extractTraceContext(headers) {
|
|
84
|
+
if (!headers) return null;
|
|
85
|
+
const tp = readHeader(headers, "traceparent");
|
|
86
|
+
if (!tp) return null;
|
|
87
|
+
const match = TRACEPARENT_RE.exec(tp);
|
|
88
|
+
if (!match) return null;
|
|
89
|
+
const [, version, traceId, spanId, flags] = match;
|
|
90
|
+
if (version === "ff") return null;
|
|
91
|
+
if (traceId === INVALID_TRACE_ID || spanId === INVALID_SPAN_ID) return null;
|
|
92
|
+
const sampled = (parseInt(flags, 16) & 1) === 1;
|
|
93
|
+
const ts = readHeader(headers, "tracestate");
|
|
94
|
+
return {
|
|
95
|
+
traceparent: tp,
|
|
96
|
+
tracestate: ts && ts.length > 0 ? ts : void 0,
|
|
97
|
+
traceId,
|
|
98
|
+
spanId,
|
|
99
|
+
sampled
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function readHeader(headers, name) {
|
|
103
|
+
const v = headers[name];
|
|
104
|
+
if (v === void 0 || v === null) return void 0;
|
|
105
|
+
if (typeof v === "string") return v;
|
|
106
|
+
if (Buffer.isBuffer(v)) return v.toString("utf8");
|
|
107
|
+
return void 0;
|
|
108
|
+
}
|
|
109
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
110
|
+
0 && (module.exports = {
|
|
111
|
+
decode,
|
|
112
|
+
decodeHeaders,
|
|
113
|
+
extractTraceContext
|
|
114
|
+
});
|
|
115
|
+
//# sourceMappingURL=consume.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/consume.ts"],"sourcesContent":["/**\n * Consumer-side helpers — paired with the publisher's outbound surface.\n *\n * eventferry is a publisher-only library, but the messages it produces are\n * consumed somewhere downstream. This module normalizes the message shape\n * that both `kafkajs` and `@confluentinc/kafka-javascript` deliver to\n * consumer callbacks, decodes the payload, and extracts the W3C trace\n * context the publisher injected (see {@link KafkaTracer.inject}).\n *\n * Imported via subpath so consumer code paths don't pull in the producer:\n *\n * import { decode, extractTraceContext } from \"@eventferry/kafka/consume\";\n *\n * There is intentionally NO Kafka client here — bring your own consumer\n * (kafkajs's `Consumer`, librdkafka's, whatever) and call `decode()` /\n * `extractTraceContext()` on the message you receive.\n */\n\n/**\n * Raw incoming Kafka message — structural subset both kafkajs and confluent\n * (via the kafkaJS-compat layer) deliver. Fields are optional because\n * different consumer APIs surface different subsets.\n */\nexport interface IncomingKafkaMessage {\n key?: Buffer | string | null;\n value?: Buffer | string | null;\n headers?: IncomingHeaders;\n /** ISO ms string or numeric epoch ms — depends on the client. */\n timestamp?: string | number;\n /** Numeric or string per client. */\n offset?: string | number;\n partition?: number;\n}\n\n/** Headers as the underlying clients deliver them: bytes, strings, or undefined. */\nexport type IncomingHeaders = Record<string, Buffer | string | undefined>;\n\n/** Headers normalized to UTF-8 strings (the form most application code wants). */\nexport type DecodedHeaders = Record<string, string>;\n\n/** Payload decoder. Buffer in, decoded value out. */\nexport type Decoder<T> = (bytes: Buffer) => T;\n\n/** Decoded message wrapper — value plus normalized headers, key, metadata. */\nexport interface DecodedMessage<V = unknown> {\n key: string | null;\n value: V | null;\n headers: DecodedHeaders;\n /** Epoch ms when the broker stamped the record. */\n timestamp?: number;\n /** Stringified offset (Kafka offsets exceed 2^53 — strings stay safe). */\n offset?: string;\n partition?: number;\n}\n\nexport interface DecodeOptions<V> {\n /**\n * Decoder for the payload bytes. Built-ins:\n *\n * - `\"json\"` (default) — `JSON.parse(value.toString(\"utf8\"))`. Empty\n * value returns `null` (matches Kafka tombstones on compacted topics).\n * - `\"utf8\"` — raw text. Returns the string as-is.\n * - `\"none\"` — returns the raw `Buffer` unchanged.\n *\n * Or pass your own `(bytes: Buffer) => V` for Avro / Protobuf / MessagePack.\n */\n decoder?: \"json\" | \"utf8\" | \"none\" | Decoder<V>;\n}\n\n/**\n * Normalize headers to a plain string→string map. Buffers are read as UTF-8;\n * `undefined` entries are dropped (consumers occasionally surface absent\n * headers as `undefined` values).\n */\nexport function decodeHeaders(raw?: IncomingHeaders): DecodedHeaders {\n if (!raw) return {};\n const out: DecodedHeaders = {};\n for (const [k, v] of Object.entries(raw)) {\n if (v === undefined || v === null) continue;\n out[k] = Buffer.isBuffer(v) ? v.toString(\"utf8\") : v;\n }\n return out;\n}\n\n/**\n * Decode a Kafka message: normalize the key + headers, decode the value\n * with the chosen decoder, and surface the broker metadata.\n *\n * Tombstones (null/empty value) come back with `value: null` regardless of\n * the decoder — compaction-friendly.\n *\n * @throws when `decoder: \"json\"` (the default) and the payload is non-empty\n * but not valid JSON. Catch the error and decide whether to DLQ the\n * record or skip it — eventferry does not assume.\n */\nexport function decode<V = unknown>(\n msg: IncomingKafkaMessage,\n opts: DecodeOptions<V> = {},\n): DecodedMessage<V> {\n const headers = decodeHeaders(msg.headers);\n const key = normalizeKey(msg.key);\n const value = decodeValue<V>(msg.value, opts.decoder ?? \"json\");\n const timestamp =\n msg.timestamp !== undefined ? Number(msg.timestamp) : undefined;\n const offset = msg.offset !== undefined ? String(msg.offset) : undefined;\n return {\n key,\n value,\n headers,\n timestamp,\n offset,\n partition: msg.partition,\n };\n}\n\nfunction normalizeKey(key?: Buffer | string | null): string | null {\n if (key === null || key === undefined) return null;\n return Buffer.isBuffer(key) ? key.toString(\"utf8\") : key;\n}\n\nfunction decodeValue<V>(\n value: Buffer | string | null | undefined,\n decoder: DecodeOptions<V>[\"decoder\"],\n): V | null {\n if (value === null || value === undefined) return null;\n const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);\n // Tombstones: an empty buffer is a kafka \"delete me\" on compacted\n // topics. Surface as null for every decoder — applications usually\n // want the same null-handling for both.\n if (buf.length === 0) return null;\n if (typeof decoder === \"function\") return decoder(buf);\n switch (decoder) {\n case \"utf8\":\n return buf.toString(\"utf8\") as unknown as V;\n case \"none\":\n return buf as unknown as V;\n case \"json\":\n case undefined:\n default: {\n const text = buf.toString(\"utf8\");\n try {\n return JSON.parse(text) as V;\n } catch (err) {\n throw new Error(\n `decode: JSON.parse failed on message value: ${(err as Error).message}`,\n );\n }\n }\n }\n}\n\n/**\n * W3C Trace Context extracted from message headers.\n *\n * - `traceparent`: full header value, format `version-traceId-spanId-flags`.\n * - `tracestate`: optional vendor-specific state (W3C `tracestate` header).\n * - `traceId`: 32 hex chars, parsed from `traceparent`.\n * - `spanId`: 16 hex chars (the PARENT span id from the producer).\n * - `sampled`: parsed from the `traceparent` flags (bit 0 = sampled).\n *\n * Returns `null` when no `traceparent` header is present or the value\n * fails W3C validation.\n *\n * Spec: https://www.w3.org/TR/trace-context/\n */\nexport interface TraceContext {\n traceparent: string;\n tracestate?: string;\n traceId: string;\n spanId: string;\n sampled: boolean;\n}\n\nconst TRACEPARENT_RE =\n /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;\nconst INVALID_TRACE_ID = \"0\".repeat(32);\nconst INVALID_SPAN_ID = \"0\".repeat(16);\n\n/**\n * Extract the W3C trace context the publisher injected into headers.\n * Headers may be raw (Buffer values) or already-decoded (string values) —\n * both shapes work, so you can call this before OR after `decode()`.\n *\n * Validation follows the W3C spec strictly: invalid all-zero trace/span\n * IDs are rejected, version `ff` is rejected, malformed hex is rejected.\n * On any of these, the function returns `null` rather than throwing —\n * consumer code should fall back to starting a fresh trace.\n */\nexport function extractTraceContext(\n headers: IncomingHeaders | DecodedHeaders | undefined,\n): TraceContext | null {\n if (!headers) return null;\n const tp = readHeader(headers, \"traceparent\");\n if (!tp) return null;\n const match = TRACEPARENT_RE.exec(tp);\n if (!match) return null;\n const [, version, traceId, spanId, flags] = match as unknown as [\n string,\n string,\n string,\n string,\n string,\n ];\n // Spec §3.2.2.5: version \"ff\" is forbidden (reserved sentinel).\n if (version === \"ff\") return null;\n if (traceId === INVALID_TRACE_ID || spanId === INVALID_SPAN_ID) return null;\n const sampled = (parseInt(flags, 16) & 0x01) === 1;\n const ts = readHeader(headers, \"tracestate\");\n return {\n traceparent: tp,\n tracestate: ts && ts.length > 0 ? ts : undefined,\n traceId,\n spanId,\n sampled,\n };\n}\n\nfunction readHeader(\n headers: IncomingHeaders | DecodedHeaders,\n name: string,\n): string | undefined {\n const v = (headers as Record<string, Buffer | string | undefined>)[name];\n if (v === undefined || v === null) return undefined;\n if (typeof v === \"string\") return v;\n if (Buffer.isBuffer(v)) return v.toString(\"utf8\");\n return undefined;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0EO,SAAS,cAAc,KAAuC;AACnE,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,QAAM,MAAsB,CAAC;AAC7B,aAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,GAAG,GAAG;AACxC,QAAI,MAAM,UAAa,MAAM,KAAM;AACnC,QAAI,CAAC,IAAI,OAAO,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,IAAI;AAAA,EACrD;AACA,SAAO;AACT;AAaO,SAAS,OACd,KACA,OAAyB,CAAC,GACP;AACnB,QAAM,UAAU,cAAc,IAAI,OAAO;AACzC,QAAM,MAAM,aAAa,IAAI,GAAG;AAChC,QAAM,QAAQ,YAAe,IAAI,OAAO,KAAK,WAAW,MAAM;AAC9D,QAAM,YACJ,IAAI,cAAc,SAAY,OAAO,IAAI,SAAS,IAAI;AACxD,QAAM,SAAS,IAAI,WAAW,SAAY,OAAO,IAAI,MAAM,IAAI;AAC/D,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,WAAW,IAAI;AAAA,EACjB;AACF;AAEA,SAAS,aAAa,KAA6C;AACjE,MAAI,QAAQ,QAAQ,QAAQ,OAAW,QAAO;AAC9C,SAAO,OAAO,SAAS,GAAG,IAAI,IAAI,SAAS,MAAM,IAAI;AACvD;AAEA,SAAS,YACP,OACA,SACU;AACV,MAAI,UAAU,QAAQ,UAAU,OAAW,QAAO;AAClD,QAAM,MAAM,OAAO,SAAS,KAAK,IAAI,QAAQ,OAAO,KAAK,KAAK;AAI9D,MAAI,IAAI,WAAW,EAAG,QAAO;AAC7B,MAAI,OAAO,YAAY,WAAY,QAAO,QAAQ,GAAG;AACrD,UAAQ,SAAS;AAAA,IACf,KAAK;AACH,aAAO,IAAI,SAAS,MAAM;AAAA,IAC5B,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AAAA,IACL,KAAK;AAAA,IACL,SAAS;AACP,YAAM,OAAO,IAAI,SAAS,MAAM;AAChC,UAAI;AACF,eAAO,KAAK,MAAM,IAAI;AAAA,MACxB,SAAS,KAAK;AACZ,cAAM,IAAI;AAAA,UACR,+CAAgD,IAAc,OAAO;AAAA,QACvE;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;AAwBA,IAAM,iBACJ;AACF,IAAM,mBAAmB,IAAI,OAAO,EAAE;AACtC,IAAM,kBAAkB,IAAI,OAAO,EAAE;AAY9B,SAAS,oBACd,SACqB;AACrB,MAAI,CAAC,QAAS,QAAO;AACrB,QAAM,KAAK,WAAW,SAAS,aAAa;AAC5C,MAAI,CAAC,GAAI,QAAO;AAChB,QAAM,QAAQ,eAAe,KAAK,EAAE;AACpC,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,CAAC,EAAE,SAAS,SAAS,QAAQ,KAAK,IAAI;AAQ5C,MAAI,YAAY,KAAM,QAAO;AAC7B,MAAI,YAAY,oBAAoB,WAAW,gBAAiB,QAAO;AACvE,QAAM,WAAW,SAAS,OAAO,EAAE,IAAI,OAAU;AACjD,QAAM,KAAK,WAAW,SAAS,YAAY;AAC3C,SAAO;AAAA,IACL,aAAa;AAAA,IACb,YAAY,MAAM,GAAG,SAAS,IAAI,KAAK;AAAA,IACvC;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAEA,SAAS,WACP,SACA,MACoB;AACpB,QAAM,IAAK,QAAwD,IAAI;AACvE,MAAI,MAAM,UAAa,MAAM,KAAM,QAAO;AAC1C,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,MAAI,OAAO,SAAS,CAAC,EAAG,QAAO,EAAE,SAAS,MAAM;AAChD,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumer-side helpers — paired with the publisher's outbound surface.
|
|
3
|
+
*
|
|
4
|
+
* eventferry is a publisher-only library, but the messages it produces are
|
|
5
|
+
* consumed somewhere downstream. This module normalizes the message shape
|
|
6
|
+
* that both `kafkajs` and `@confluentinc/kafka-javascript` deliver to
|
|
7
|
+
* consumer callbacks, decodes the payload, and extracts the W3C trace
|
|
8
|
+
* context the publisher injected (see {@link KafkaTracer.inject}).
|
|
9
|
+
*
|
|
10
|
+
* Imported via subpath so consumer code paths don't pull in the producer:
|
|
11
|
+
*
|
|
12
|
+
* import { decode, extractTraceContext } from "@eventferry/kafka/consume";
|
|
13
|
+
*
|
|
14
|
+
* There is intentionally NO Kafka client here — bring your own consumer
|
|
15
|
+
* (kafkajs's `Consumer`, librdkafka's, whatever) and call `decode()` /
|
|
16
|
+
* `extractTraceContext()` on the message you receive.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Raw incoming Kafka message — structural subset both kafkajs and confluent
|
|
20
|
+
* (via the kafkaJS-compat layer) deliver. Fields are optional because
|
|
21
|
+
* different consumer APIs surface different subsets.
|
|
22
|
+
*/
|
|
23
|
+
interface IncomingKafkaMessage {
|
|
24
|
+
key?: Buffer | string | null;
|
|
25
|
+
value?: Buffer | string | null;
|
|
26
|
+
headers?: IncomingHeaders;
|
|
27
|
+
/** ISO ms string or numeric epoch ms — depends on the client. */
|
|
28
|
+
timestamp?: string | number;
|
|
29
|
+
/** Numeric or string per client. */
|
|
30
|
+
offset?: string | number;
|
|
31
|
+
partition?: number;
|
|
32
|
+
}
|
|
33
|
+
/** Headers as the underlying clients deliver them: bytes, strings, or undefined. */
|
|
34
|
+
type IncomingHeaders = Record<string, Buffer | string | undefined>;
|
|
35
|
+
/** Headers normalized to UTF-8 strings (the form most application code wants). */
|
|
36
|
+
type DecodedHeaders = Record<string, string>;
|
|
37
|
+
/** Payload decoder. Buffer in, decoded value out. */
|
|
38
|
+
type Decoder<T> = (bytes: Buffer) => T;
|
|
39
|
+
/** Decoded message wrapper — value plus normalized headers, key, metadata. */
|
|
40
|
+
interface DecodedMessage<V = unknown> {
|
|
41
|
+
key: string | null;
|
|
42
|
+
value: V | null;
|
|
43
|
+
headers: DecodedHeaders;
|
|
44
|
+
/** Epoch ms when the broker stamped the record. */
|
|
45
|
+
timestamp?: number;
|
|
46
|
+
/** Stringified offset (Kafka offsets exceed 2^53 — strings stay safe). */
|
|
47
|
+
offset?: string;
|
|
48
|
+
partition?: number;
|
|
49
|
+
}
|
|
50
|
+
interface DecodeOptions<V> {
|
|
51
|
+
/**
|
|
52
|
+
* Decoder for the payload bytes. Built-ins:
|
|
53
|
+
*
|
|
54
|
+
* - `"json"` (default) — `JSON.parse(value.toString("utf8"))`. Empty
|
|
55
|
+
* value returns `null` (matches Kafka tombstones on compacted topics).
|
|
56
|
+
* - `"utf8"` — raw text. Returns the string as-is.
|
|
57
|
+
* - `"none"` — returns the raw `Buffer` unchanged.
|
|
58
|
+
*
|
|
59
|
+
* Or pass your own `(bytes: Buffer) => V` for Avro / Protobuf / MessagePack.
|
|
60
|
+
*/
|
|
61
|
+
decoder?: "json" | "utf8" | "none" | Decoder<V>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Normalize headers to a plain string→string map. Buffers are read as UTF-8;
|
|
65
|
+
* `undefined` entries are dropped (consumers occasionally surface absent
|
|
66
|
+
* headers as `undefined` values).
|
|
67
|
+
*/
|
|
68
|
+
declare function decodeHeaders(raw?: IncomingHeaders): DecodedHeaders;
|
|
69
|
+
/**
|
|
70
|
+
* Decode a Kafka message: normalize the key + headers, decode the value
|
|
71
|
+
* with the chosen decoder, and surface the broker metadata.
|
|
72
|
+
*
|
|
73
|
+
* Tombstones (null/empty value) come back with `value: null` regardless of
|
|
74
|
+
* the decoder — compaction-friendly.
|
|
75
|
+
*
|
|
76
|
+
* @throws when `decoder: "json"` (the default) and the payload is non-empty
|
|
77
|
+
* but not valid JSON. Catch the error and decide whether to DLQ the
|
|
78
|
+
* record or skip it — eventferry does not assume.
|
|
79
|
+
*/
|
|
80
|
+
declare function decode<V = unknown>(msg: IncomingKafkaMessage, opts?: DecodeOptions<V>): DecodedMessage<V>;
|
|
81
|
+
/**
|
|
82
|
+
* W3C Trace Context extracted from message headers.
|
|
83
|
+
*
|
|
84
|
+
* - `traceparent`: full header value, format `version-traceId-spanId-flags`.
|
|
85
|
+
* - `tracestate`: optional vendor-specific state (W3C `tracestate` header).
|
|
86
|
+
* - `traceId`: 32 hex chars, parsed from `traceparent`.
|
|
87
|
+
* - `spanId`: 16 hex chars (the PARENT span id from the producer).
|
|
88
|
+
* - `sampled`: parsed from the `traceparent` flags (bit 0 = sampled).
|
|
89
|
+
*
|
|
90
|
+
* Returns `null` when no `traceparent` header is present or the value
|
|
91
|
+
* fails W3C validation.
|
|
92
|
+
*
|
|
93
|
+
* Spec: https://www.w3.org/TR/trace-context/
|
|
94
|
+
*/
|
|
95
|
+
interface TraceContext {
|
|
96
|
+
traceparent: string;
|
|
97
|
+
tracestate?: string;
|
|
98
|
+
traceId: string;
|
|
99
|
+
spanId: string;
|
|
100
|
+
sampled: boolean;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Extract the W3C trace context the publisher injected into headers.
|
|
104
|
+
* Headers may be raw (Buffer values) or already-decoded (string values) —
|
|
105
|
+
* both shapes work, so you can call this before OR after `decode()`.
|
|
106
|
+
*
|
|
107
|
+
* Validation follows the W3C spec strictly: invalid all-zero trace/span
|
|
108
|
+
* IDs are rejected, version `ff` is rejected, malformed hex is rejected.
|
|
109
|
+
* On any of these, the function returns `null` rather than throwing —
|
|
110
|
+
* consumer code should fall back to starting a fresh trace.
|
|
111
|
+
*/
|
|
112
|
+
declare function extractTraceContext(headers: IncomingHeaders | DecodedHeaders | undefined): TraceContext | null;
|
|
113
|
+
|
|
114
|
+
export { type DecodeOptions, type DecodedHeaders, type DecodedMessage, type Decoder, type IncomingHeaders, type IncomingKafkaMessage, type TraceContext, decode, decodeHeaders, extractTraceContext };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consumer-side helpers — paired with the publisher's outbound surface.
|
|
3
|
+
*
|
|
4
|
+
* eventferry is a publisher-only library, but the messages it produces are
|
|
5
|
+
* consumed somewhere downstream. This module normalizes the message shape
|
|
6
|
+
* that both `kafkajs` and `@confluentinc/kafka-javascript` deliver to
|
|
7
|
+
* consumer callbacks, decodes the payload, and extracts the W3C trace
|
|
8
|
+
* context the publisher injected (see {@link KafkaTracer.inject}).
|
|
9
|
+
*
|
|
10
|
+
* Imported via subpath so consumer code paths don't pull in the producer:
|
|
11
|
+
*
|
|
12
|
+
* import { decode, extractTraceContext } from "@eventferry/kafka/consume";
|
|
13
|
+
*
|
|
14
|
+
* There is intentionally NO Kafka client here — bring your own consumer
|
|
15
|
+
* (kafkajs's `Consumer`, librdkafka's, whatever) and call `decode()` /
|
|
16
|
+
* `extractTraceContext()` on the message you receive.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Raw incoming Kafka message — structural subset both kafkajs and confluent
|
|
20
|
+
* (via the kafkaJS-compat layer) deliver. Fields are optional because
|
|
21
|
+
* different consumer APIs surface different subsets.
|
|
22
|
+
*/
|
|
23
|
+
interface IncomingKafkaMessage {
|
|
24
|
+
key?: Buffer | string | null;
|
|
25
|
+
value?: Buffer | string | null;
|
|
26
|
+
headers?: IncomingHeaders;
|
|
27
|
+
/** ISO ms string or numeric epoch ms — depends on the client. */
|
|
28
|
+
timestamp?: string | number;
|
|
29
|
+
/** Numeric or string per client. */
|
|
30
|
+
offset?: string | number;
|
|
31
|
+
partition?: number;
|
|
32
|
+
}
|
|
33
|
+
/** Headers as the underlying clients deliver them: bytes, strings, or undefined. */
|
|
34
|
+
type IncomingHeaders = Record<string, Buffer | string | undefined>;
|
|
35
|
+
/** Headers normalized to UTF-8 strings (the form most application code wants). */
|
|
36
|
+
type DecodedHeaders = Record<string, string>;
|
|
37
|
+
/** Payload decoder. Buffer in, decoded value out. */
|
|
38
|
+
type Decoder<T> = (bytes: Buffer) => T;
|
|
39
|
+
/** Decoded message wrapper — value plus normalized headers, key, metadata. */
|
|
40
|
+
interface DecodedMessage<V = unknown> {
|
|
41
|
+
key: string | null;
|
|
42
|
+
value: V | null;
|
|
43
|
+
headers: DecodedHeaders;
|
|
44
|
+
/** Epoch ms when the broker stamped the record. */
|
|
45
|
+
timestamp?: number;
|
|
46
|
+
/** Stringified offset (Kafka offsets exceed 2^53 — strings stay safe). */
|
|
47
|
+
offset?: string;
|
|
48
|
+
partition?: number;
|
|
49
|
+
}
|
|
50
|
+
interface DecodeOptions<V> {
|
|
51
|
+
/**
|
|
52
|
+
* Decoder for the payload bytes. Built-ins:
|
|
53
|
+
*
|
|
54
|
+
* - `"json"` (default) — `JSON.parse(value.toString("utf8"))`. Empty
|
|
55
|
+
* value returns `null` (matches Kafka tombstones on compacted topics).
|
|
56
|
+
* - `"utf8"` — raw text. Returns the string as-is.
|
|
57
|
+
* - `"none"` — returns the raw `Buffer` unchanged.
|
|
58
|
+
*
|
|
59
|
+
* Or pass your own `(bytes: Buffer) => V` for Avro / Protobuf / MessagePack.
|
|
60
|
+
*/
|
|
61
|
+
decoder?: "json" | "utf8" | "none" | Decoder<V>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Normalize headers to a plain string→string map. Buffers are read as UTF-8;
|
|
65
|
+
* `undefined` entries are dropped (consumers occasionally surface absent
|
|
66
|
+
* headers as `undefined` values).
|
|
67
|
+
*/
|
|
68
|
+
declare function decodeHeaders(raw?: IncomingHeaders): DecodedHeaders;
|
|
69
|
+
/**
|
|
70
|
+
* Decode a Kafka message: normalize the key + headers, decode the value
|
|
71
|
+
* with the chosen decoder, and surface the broker metadata.
|
|
72
|
+
*
|
|
73
|
+
* Tombstones (null/empty value) come back with `value: null` regardless of
|
|
74
|
+
* the decoder — compaction-friendly.
|
|
75
|
+
*
|
|
76
|
+
* @throws when `decoder: "json"` (the default) and the payload is non-empty
|
|
77
|
+
* but not valid JSON. Catch the error and decide whether to DLQ the
|
|
78
|
+
* record or skip it — eventferry does not assume.
|
|
79
|
+
*/
|
|
80
|
+
declare function decode<V = unknown>(msg: IncomingKafkaMessage, opts?: DecodeOptions<V>): DecodedMessage<V>;
|
|
81
|
+
/**
|
|
82
|
+
* W3C Trace Context extracted from message headers.
|
|
83
|
+
*
|
|
84
|
+
* - `traceparent`: full header value, format `version-traceId-spanId-flags`.
|
|
85
|
+
* - `tracestate`: optional vendor-specific state (W3C `tracestate` header).
|
|
86
|
+
* - `traceId`: 32 hex chars, parsed from `traceparent`.
|
|
87
|
+
* - `spanId`: 16 hex chars (the PARENT span id from the producer).
|
|
88
|
+
* - `sampled`: parsed from the `traceparent` flags (bit 0 = sampled).
|
|
89
|
+
*
|
|
90
|
+
* Returns `null` when no `traceparent` header is present or the value
|
|
91
|
+
* fails W3C validation.
|
|
92
|
+
*
|
|
93
|
+
* Spec: https://www.w3.org/TR/trace-context/
|
|
94
|
+
*/
|
|
95
|
+
interface TraceContext {
|
|
96
|
+
traceparent: string;
|
|
97
|
+
tracestate?: string;
|
|
98
|
+
traceId: string;
|
|
99
|
+
spanId: string;
|
|
100
|
+
sampled: boolean;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Extract the W3C trace context the publisher injected into headers.
|
|
104
|
+
* Headers may be raw (Buffer values) or already-decoded (string values) —
|
|
105
|
+
* both shapes work, so you can call this before OR after `decode()`.
|
|
106
|
+
*
|
|
107
|
+
* Validation follows the W3C spec strictly: invalid all-zero trace/span
|
|
108
|
+
* IDs are rejected, version `ff` is rejected, malformed hex is rejected.
|
|
109
|
+
* On any of these, the function returns `null` rather than throwing —
|
|
110
|
+
* consumer code should fall back to starting a fresh trace.
|
|
111
|
+
*/
|
|
112
|
+
declare function extractTraceContext(headers: IncomingHeaders | DecodedHeaders | undefined): TraceContext | null;
|
|
113
|
+
|
|
114
|
+
export { type DecodeOptions, type DecodedHeaders, type DecodedMessage, type Decoder, type IncomingHeaders, type IncomingKafkaMessage, type TraceContext, decode, decodeHeaders, extractTraceContext };
|
package/dist/consume.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// src/consume.ts
|
|
2
|
+
function decodeHeaders(raw) {
|
|
3
|
+
if (!raw) return {};
|
|
4
|
+
const out = {};
|
|
5
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
6
|
+
if (v === void 0 || v === null) continue;
|
|
7
|
+
out[k] = Buffer.isBuffer(v) ? v.toString("utf8") : v;
|
|
8
|
+
}
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
function decode(msg, opts = {}) {
|
|
12
|
+
const headers = decodeHeaders(msg.headers);
|
|
13
|
+
const key = normalizeKey(msg.key);
|
|
14
|
+
const value = decodeValue(msg.value, opts.decoder ?? "json");
|
|
15
|
+
const timestamp = msg.timestamp !== void 0 ? Number(msg.timestamp) : void 0;
|
|
16
|
+
const offset = msg.offset !== void 0 ? String(msg.offset) : void 0;
|
|
17
|
+
return {
|
|
18
|
+
key,
|
|
19
|
+
value,
|
|
20
|
+
headers,
|
|
21
|
+
timestamp,
|
|
22
|
+
offset,
|
|
23
|
+
partition: msg.partition
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function normalizeKey(key) {
|
|
27
|
+
if (key === null || key === void 0) return null;
|
|
28
|
+
return Buffer.isBuffer(key) ? key.toString("utf8") : key;
|
|
29
|
+
}
|
|
30
|
+
function decodeValue(value, decoder) {
|
|
31
|
+
if (value === null || value === void 0) return null;
|
|
32
|
+
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
33
|
+
if (buf.length === 0) return null;
|
|
34
|
+
if (typeof decoder === "function") return decoder(buf);
|
|
35
|
+
switch (decoder) {
|
|
36
|
+
case "utf8":
|
|
37
|
+
return buf.toString("utf8");
|
|
38
|
+
case "none":
|
|
39
|
+
return buf;
|
|
40
|
+
case "json":
|
|
41
|
+
case void 0:
|
|
42
|
+
default: {
|
|
43
|
+
const text = buf.toString("utf8");
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(text);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`decode: JSON.parse failed on message value: ${err.message}`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
var TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
55
|
+
var INVALID_TRACE_ID = "0".repeat(32);
|
|
56
|
+
var INVALID_SPAN_ID = "0".repeat(16);
|
|
57
|
+
function extractTraceContext(headers) {
|
|
58
|
+
if (!headers) return null;
|
|
59
|
+
const tp = readHeader(headers, "traceparent");
|
|
60
|
+
if (!tp) return null;
|
|
61
|
+
const match = TRACEPARENT_RE.exec(tp);
|
|
62
|
+
if (!match) return null;
|
|
63
|
+
const [, version, traceId, spanId, flags] = match;
|
|
64
|
+
if (version === "ff") return null;
|
|
65
|
+
if (traceId === INVALID_TRACE_ID || spanId === INVALID_SPAN_ID) return null;
|
|
66
|
+
const sampled = (parseInt(flags, 16) & 1) === 1;
|
|
67
|
+
const ts = readHeader(headers, "tracestate");
|
|
68
|
+
return {
|
|
69
|
+
traceparent: tp,
|
|
70
|
+
tracestate: ts && ts.length > 0 ? ts : void 0,
|
|
71
|
+
traceId,
|
|
72
|
+
spanId,
|
|
73
|
+
sampled
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function readHeader(headers, name) {
|
|
77
|
+
const v = headers[name];
|
|
78
|
+
if (v === void 0 || v === null) return void 0;
|
|
79
|
+
if (typeof v === "string") return v;
|
|
80
|
+
if (Buffer.isBuffer(v)) return v.toString("utf8");
|
|
81
|
+
return void 0;
|
|
82
|
+
}
|
|
83
|
+
export {
|
|
84
|
+
decode,
|
|
85
|
+
decodeHeaders,
|
|
86
|
+
extractTraceContext
|
|
87
|
+
};
|
|
88
|
+
//# sourceMappingURL=consume.js.map
|