@eventferry/schema-registry 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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @eventferry/schema-registry
2
2
 
3
+ ## 3.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0f328d2: Typed authentication for the Schema Registry HTTP API. New `auth?: SchemaRegistryAuth` option on `SchemaRegistrySerializer` with two shapes:
8
+
9
+ - `{ type: "basic", username, password }` — HTTP Basic, forwarded straight to the underlying client's `auth` config (the conventional Confluent Cloud + commercial-registry shape).
10
+ - `{ type: "bearer", token: string | () => string | Promise<string> }` — adds an `Authorization: Bearer <token>` header via a small middleware on the upstream client. Callable tokens are resolved on **every** request, so rotation logic lives in the caller's provider (cache inside your callable if rotation cost matters).
11
+
12
+ Ignored when an already-constructed `registry` client is injected — configure auth there yourself. mTLS to the registry stays out of scope: it's handled by a custom `https.Agent` on a self-constructed client (registry TLS is independent of broker TLS, and we don't want to fold an unrelated knob into this surface).
13
+
14
+ The middleware factory is exported as `bearerAuthMiddleware` for testing only — not part of the supported public API and may move in a future version.
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [715523f]
19
+ - Updated dependencies [fb0549d]
20
+ - @eventferry/core@3.4.0
21
+
3
22
  ## 3.3.0
4
23
 
5
24
  ### Minor Changes
package/README.md CHANGED
@@ -32,6 +32,37 @@ Topics without a configured schema use the subject's latest registered schema
32
32
  (default subject: `${topic}-value`). On the consumer, decode with the same client:
33
33
  `await registry.decode(message.value)`.
34
34
 
35
+ ## Authentication
36
+
37
+ The serializer accepts the two HTTP auth shapes Confluent Schema Registry installations use:
38
+
39
+ ```ts
40
+ // HTTP Basic — Confluent Cloud + most commercial registries.
41
+ new SchemaRegistrySerializer({
42
+ host,
43
+ auth: { type: "basic", username: "<api-key>", password: "<api-secret>" },
44
+ });
45
+
46
+ // Bearer token — OIDC, custom SR proxies, etc.
47
+ new SchemaRegistrySerializer({
48
+ host,
49
+ auth: { type: "bearer", token: "eyJhbGc..." },
50
+ });
51
+
52
+ // Rotating bearer token (refresh per request — cache inside your provider).
53
+ new SchemaRegistrySerializer({
54
+ host,
55
+ auth: {
56
+ type: "bearer",
57
+ token: async () => await getCachedAccessToken(),
58
+ },
59
+ });
60
+ ```
61
+
62
+ Bearer tokens are injected via a small middleware on the underlying client — the provider is invoked on **every** request, so you control rotation. `auth` is ignored when you pass an already-constructed `registry` client (configure auth there yourself).
63
+
64
+ For **mTLS** to the registry, supply a custom `https.Agent` on a self-constructed client and pass it via the `registry` option. The serializer does not surface a separate `tls` block — registry TLS is independent of broker TLS and `https.Agent` is the standard Node entry point.
65
+
35
66
  ## Subject naming strategies
36
67
 
37
68
  Pick one of Confluent's three built-in strategies (default `TopicNameStrategy`):
package/dist/index.cjs CHANGED
@@ -30,7 +30,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
- SchemaRegistrySerializer: () => SchemaRegistrySerializer
33
+ SchemaRegistrySerializer: () => SchemaRegistrySerializer,
34
+ bearerAuthMiddleware: () => bearerAuthMiddleware
34
35
  });
35
36
  module.exports = __toCommonJS(index_exports);
36
37
 
@@ -44,6 +45,7 @@ var SchemaRegistrySerializer = class {
44
45
  recordName;
45
46
  subjectFn;
46
47
  host;
48
+ auth;
47
49
  autoRegister;
48
50
  // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.
49
51
  idCache = /* @__PURE__ */ new Map();
@@ -56,6 +58,7 @@ var SchemaRegistrySerializer = class {
56
58
  }
57
59
  this.registry = opts.registry ?? null;
58
60
  this.host = opts.host ?? null;
61
+ this.auth = opts.auth ?? null;
59
62
  this.schemas = opts.schemas ?? {};
60
63
  this.keySchemas = opts.keySchemas ?? {};
61
64
  this.subjectStrategy = opts.subjectStrategy ?? "TopicNameStrategy";
@@ -130,10 +133,32 @@ var SchemaRegistrySerializer = class {
130
133
  async getRegistry() {
131
134
  if (this.registry) return this.registry;
132
135
  const mod = await importSchemaRegistry();
133
- this.registry = new mod.SchemaRegistry({ host: this.host });
136
+ const cfg = {
137
+ host: this.host
138
+ };
139
+ if (this.auth) {
140
+ if (this.auth.type === "basic") {
141
+ cfg.auth = {
142
+ username: this.auth.username,
143
+ password: this.auth.password
144
+ };
145
+ } else {
146
+ cfg.middlewares = [bearerAuthMiddleware(this.auth.token)];
147
+ }
148
+ }
149
+ this.registry = new mod.SchemaRegistry(cfg);
134
150
  return this.registry;
135
151
  }
136
152
  };
153
+ function bearerAuthMiddleware(token) {
154
+ return () => ({
155
+ async prepareRequest(next) {
156
+ const request = await next();
157
+ const value = typeof token === "function" ? await token() : token;
158
+ return request.enhance({ headers: { Authorization: `Bearer ${value}` } });
159
+ }
160
+ });
161
+ }
137
162
  async function importSchemaRegistry() {
138
163
  try {
139
164
  return await import("@kafkajs/confluent-schema-registry");
@@ -145,6 +170,7 @@ async function importSchemaRegistry() {
145
170
  }
146
171
  // Annotate the CommonJS export names for ESM import in node:
147
172
  0 && (module.exports = {
148
- SchemaRegistrySerializer
173
+ SchemaRegistrySerializer,
174
+ bearerAuthMiddleware
149
175
  });
150
176
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/serializer.ts"],"sourcesContent":["export * from \"./serializer.js\";\n","import type { OutboxRecord, Serializer } from \"@eventferry/core\";\n\nexport type SchemaType = \"AVRO\" | \"PROTOBUF\" | \"JSON\";\n\nexport interface SchemaSpec {\n type: SchemaType;\n /** Schema definition string (avsc JSON / .proto / JSON Schema). */\n schema: string;\n}\n\n/**\n * Subject naming strategy. Mirrors Confluent's three built-ins:\n *\n * - `\"TopicNameStrategy\"` (default) — `${topic}-value` / `${topic}-key`.\n * The conventional default; one schema per (topic, isKey) tuple.\n * - `\"RecordNameStrategy\"` — `${recordName}`. Same record type can flow\n * on multiple topics. Requires a `recordName` resolver.\n * - `\"TopicRecordNameStrategy\"` — `${topic}-${recordName}`. Multiple record\n * types per topic. Requires a `recordName` resolver.\n *\n * Set `subject` (function form) to override entirely.\n */\nexport type SubjectNameStrategy =\n | \"TopicNameStrategy\"\n | \"RecordNameStrategy\"\n | \"TopicRecordNameStrategy\";\n\n/**\n * The subset of a Confluent Schema Registry client this serializer uses. The\n * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.\n */\nexport interface SchemaRegistryClient {\n register(\n schema: { type: string; schema: string },\n opts?: { subject: string },\n ): Promise<{ id: number }>;\n getLatestSchemaId(subject: string): Promise<number>;\n encode(registryId: number, payload: unknown): Promise<Buffer>;\n}\n\nexport interface SchemaRegistrySerializerOptions {\n /** Inject a ready client (tests, custom config). */\n registry?: SchemaRegistryClient;\n /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */\n host?: string;\n /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */\n schemas?: Record<string, SchemaSpec>;\n /**\n * Per-topic KEY schema. When set, `serializeKey(record)` Avro-encodes\n * the record key for the matching topic. Topics omitted here fall back\n * to the subject's latest (or, with `autoRegister: false`, ALWAYS the\n * subject's latest).\n */\n keySchemas?: Record<string, SchemaSpec>;\n /**\n * Subject naming strategy preset. Default `\"TopicNameStrategy\"`.\n * Setting `subject` (function) overrides this entirely.\n */\n subjectStrategy?: SubjectNameStrategy;\n /**\n * Resolve the schema's record name (used by `RecordNameStrategy` and\n * `TopicRecordNameStrategy`). REQUIRED when `subjectStrategy` is set to\n * one of those — throws on first serialize if absent.\n *\n * Typical implementation: read `${namespace}.${name}` from the avsc you\n * already supply via `schemas` / `keySchemas`.\n */\n recordName?: (record: OutboxRecord, isKey: boolean) => string;\n /**\n * Custom subject function — overrides BOTH `subjectStrategy` and\n * `recordName`. Receives `(topic, isKey, record)` for full flexibility.\n *\n * Backwards-compatible with the single-argument legacy form\n * `(topic) => string` — extra args are ignored by JavaScript.\n */\n subject?: (\n topic: string,\n isKey?: boolean,\n record?: OutboxRecord,\n ) => string;\n /** content-type header value. Default \"application/vnd.confluent.avro\". */\n contentType?: string;\n /**\n * Auto-register schemas when one is supplied via `schemas` / `keySchemas`.\n * Default `true` — matches Confluent client behavior.\n *\n * Set to `false` for production clusters where schemas are managed\n * out-of-band (Confluent Cloud, regulated environments). With\n * autoRegister off, the serializer ALWAYS resolves by `getLatestSchemaId`\n * on the computed subject — and the locally-supplied schema bytes are\n * ignored.\n */\n autoRegister?: boolean;\n}\n\nconst DEFAULT_CONTENT_TYPE = \"application/vnd.confluent.avro\";\n\n/**\n * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry\n * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s\n * `serializer` option. The schema id per (topic, isKey) tuple is resolved once\n * and cached.\n *\n * Also exposes `serializeKey(record)` for users who want Avro-encoded message\n * keys — call it manually when building the publish path; the relay does NOT\n * call it automatically (key encoding is application-level by convention).\n */\nexport class SchemaRegistrySerializer implements Serializer {\n readonly contentType: string;\n private readonly schemas: Record<string, SchemaSpec>;\n private readonly keySchemas: Record<string, SchemaSpec>;\n private readonly subjectStrategy: SubjectNameStrategy;\n private readonly recordName:\n | ((record: OutboxRecord, isKey: boolean) => string)\n | null;\n private readonly subjectFn:\n | ((topic: string, isKey?: boolean, record?: OutboxRecord) => string)\n | null;\n private readonly host: string | null;\n private readonly autoRegister: boolean;\n // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.\n private readonly idCache = new Map<string, Promise<number>>();\n private registry: SchemaRegistryClient | null;\n\n constructor(opts: SchemaRegistrySerializerOptions) {\n if (!opts.registry && !opts.host) {\n throw new Error(\n \"SchemaRegistrySerializer requires either a `registry` client or a `host`.\",\n );\n }\n this.registry = opts.registry ?? null;\n this.host = opts.host ?? null;\n this.schemas = opts.schemas ?? {};\n this.keySchemas = opts.keySchemas ?? {};\n this.subjectStrategy = opts.subjectStrategy ?? \"TopicNameStrategy\";\n this.recordName = opts.recordName ?? null;\n this.subjectFn = opts.subject ?? null;\n this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;\n this.autoRegister = opts.autoRegister ?? true;\n }\n\n async serialize(record: OutboxRecord): Promise<Buffer> {\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, false);\n const id = await this.schemaId(registry, subject, this.schemas[record.topic], false, record.topic);\n return registry.encode(id, record.payload);\n }\n\n /**\n * Avro-encode the record's KEY using the registered key schema. Returns\n * `null` when the record has no key (kafkajs/confluent treat null keys\n * as the producer-side \"no key\" signal).\n *\n * Not part of the core `Serializer` interface — callers wire it into\n * their publish path manually when they want Avro keys instead of raw\n * UTF-8 strings.\n */\n async serializeKey(record: OutboxRecord): Promise<Buffer | null> {\n if (record.key === null || record.key === undefined) return null;\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, true);\n const id = await this.schemaId(\n registry,\n subject,\n this.keySchemas[record.topic],\n true,\n record.topic,\n );\n return registry.encode(id, record.key);\n }\n\n /**\n * Resolve the subject for this (record, isKey) tuple. Order of\n * precedence: explicit `subject` function → `subjectStrategy` preset.\n */\n private resolveSubject(record: OutboxRecord, isKey: boolean): string {\n if (this.subjectFn) return this.subjectFn(record.topic, isKey, record);\n switch (this.subjectStrategy) {\n case \"TopicNameStrategy\":\n return `${record.topic}-${isKey ? \"key\" : \"value\"}`;\n case \"RecordNameStrategy\":\n return this.recordNameFor(record, isKey);\n case \"TopicRecordNameStrategy\":\n return `${record.topic}-${this.recordNameFor(record, isKey)}`;\n }\n }\n\n private recordNameFor(record: OutboxRecord, isKey: boolean): string {\n if (!this.recordName) {\n throw new Error(\n `SchemaRegistrySerializer: subjectStrategy \"${this.subjectStrategy}\" requires a \\`recordName\\` resolver.`,\n );\n }\n return this.recordName(record, isKey);\n }\n\n private schemaId(\n registry: SchemaRegistryClient,\n subject: string,\n spec: SchemaSpec | undefined,\n isKey: boolean,\n topic: string,\n ): Promise<number> {\n const cacheKey = `${topic}:${isKey ? \"key\" : \"value\"}`;\n const cached = this.idCache.get(cacheKey);\n if (cached) return cached;\n\n // autoRegister=false → ALWAYS resolve by latest; the local spec\n // (if any) is ignored. Matches Confluent's auto.register.schemas=false.\n const lookup =\n spec && this.autoRegister\n ? registry\n .register({ type: spec.type, schema: spec.schema }, { subject })\n .then((r) => r.id)\n : registry.getLatestSchemaId(subject);\n\n // Cache the in-flight promise so concurrent first calls don't double-register;\n // drop it on failure so a transient error can be retried.\n const guarded = lookup.catch((err) => {\n this.idCache.delete(cacheKey);\n throw err;\n });\n this.idCache.set(cacheKey, guarded);\n return guarded;\n }\n\n private async getRegistry(): Promise<SchemaRegistryClient> {\n if (this.registry) return this.registry;\n const mod = await importSchemaRegistry();\n this.registry = new mod.SchemaRegistry({ host: this.host as string });\n return this.registry;\n }\n}\n\nasync function importSchemaRegistry(): Promise<{\n SchemaRegistry: new (cfg: { host: string }) => SchemaRegistryClient;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"@kafkajs/confluent-schema-registry\")) as any;\n } catch {\n throw new Error(\n 'SchemaRegistrySerializer with `host` needs the \"@kafkajs/confluent-schema-registry\" package. Run: npm i @kafkajs/confluent-schema-registry',\n );\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/serializer.ts"],"sourcesContent":["export * from \"./serializer.js\";\n","import type { OutboxRecord, Serializer } from \"@eventferry/core\";\n\nexport type SchemaType = \"AVRO\" | \"PROTOBUF\" | \"JSON\";\n\nexport interface SchemaSpec {\n type: SchemaType;\n /** Schema definition string (avsc JSON / .proto / JSON Schema). */\n schema: string;\n}\n\n/**\n * Subject naming strategy. Mirrors Confluent's three built-ins:\n *\n * - `\"TopicNameStrategy\"` (default) — `${topic}-value` / `${topic}-key`.\n * The conventional default; one schema per (topic, isKey) tuple.\n * - `\"RecordNameStrategy\"` — `${recordName}`. Same record type can flow\n * on multiple topics. Requires a `recordName` resolver.\n * - `\"TopicRecordNameStrategy\"` — `${topic}-${recordName}`. Multiple record\n * types per topic. Requires a `recordName` resolver.\n *\n * Set `subject` (function form) to override entirely.\n */\nexport type SubjectNameStrategy =\n | \"TopicNameStrategy\"\n | \"RecordNameStrategy\"\n | \"TopicRecordNameStrategy\";\n\n/**\n * Authentication for the Schema Registry HTTP API.\n *\n * - `\"basic\"` — HTTP Basic Auth. The shape Confluent Cloud and most\n * commercial registries use; passed straight through to the underlying\n * client's `auth` config.\n * - `\"bearer\"` — `Authorization: Bearer <token>` header. The `token`\n * field accepts either a static string OR a callable that resolves a\n * fresh token on every request (cache inside your callable to amortise\n * cost; we don't memoize for you, so OAuth refresh-loop logic lives\n * on your side).\n *\n * mTLS for the registry connection itself is handled by Node's `tls`\n * stack — supply a custom `https.Agent` via the upstream client's\n * `agent` option (use the `registry` injection here and configure it\n * yourself), separate from the broker TLS the publisher uses.\n */\nexport type SchemaRegistryAuth =\n | { type: \"basic\"; username: string; password: string }\n | {\n type: \"bearer\";\n token: string | (() => string | Promise<string>);\n };\n\n/**\n * The subset of a Confluent Schema Registry client this serializer uses. The\n * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.\n */\nexport interface SchemaRegistryClient {\n register(\n schema: { type: string; schema: string },\n opts?: { subject: string },\n ): Promise<{ id: number }>;\n getLatestSchemaId(subject: string): Promise<number>;\n encode(registryId: number, payload: unknown): Promise<Buffer>;\n}\n\nexport interface SchemaRegistrySerializerOptions {\n /** Inject a ready client (tests, custom config). */\n registry?: SchemaRegistryClient;\n /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */\n host?: string;\n /**\n * Optional authentication for the Schema Registry HTTP API. See\n * {@link SchemaRegistryAuth} for the two supported shapes. Ignored\n * when `registry` is provided (configure auth on the injected client\n * yourself in that case).\n */\n auth?: SchemaRegistryAuth;\n /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */\n schemas?: Record<string, SchemaSpec>;\n /**\n * Per-topic KEY schema. When set, `serializeKey(record)` Avro-encodes\n * the record key for the matching topic. Topics omitted here fall back\n * to the subject's latest (or, with `autoRegister: false`, ALWAYS the\n * subject's latest).\n */\n keySchemas?: Record<string, SchemaSpec>;\n /**\n * Subject naming strategy preset. Default `\"TopicNameStrategy\"`.\n * Setting `subject` (function) overrides this entirely.\n */\n subjectStrategy?: SubjectNameStrategy;\n /**\n * Resolve the schema's record name (used by `RecordNameStrategy` and\n * `TopicRecordNameStrategy`). REQUIRED when `subjectStrategy` is set to\n * one of those — throws on first serialize if absent.\n *\n * Typical implementation: read `${namespace}.${name}` from the avsc you\n * already supply via `schemas` / `keySchemas`.\n */\n recordName?: (record: OutboxRecord, isKey: boolean) => string;\n /**\n * Custom subject function — overrides BOTH `subjectStrategy` and\n * `recordName`. Receives `(topic, isKey, record)` for full flexibility.\n *\n * Backwards-compatible with the single-argument legacy form\n * `(topic) => string` — extra args are ignored by JavaScript.\n */\n subject?: (\n topic: string,\n isKey?: boolean,\n record?: OutboxRecord,\n ) => string;\n /** content-type header value. Default \"application/vnd.confluent.avro\". */\n contentType?: string;\n /**\n * Auto-register schemas when one is supplied via `schemas` / `keySchemas`.\n * Default `true` — matches Confluent client behavior.\n *\n * Set to `false` for production clusters where schemas are managed\n * out-of-band (Confluent Cloud, regulated environments). With\n * autoRegister off, the serializer ALWAYS resolves by `getLatestSchemaId`\n * on the computed subject — and the locally-supplied schema bytes are\n * ignored.\n */\n autoRegister?: boolean;\n}\n\nconst DEFAULT_CONTENT_TYPE = \"application/vnd.confluent.avro\";\n\n/**\n * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry\n * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s\n * `serializer` option. The schema id per (topic, isKey) tuple is resolved once\n * and cached.\n *\n * Also exposes `serializeKey(record)` for users who want Avro-encoded message\n * keys — call it manually when building the publish path; the relay does NOT\n * call it automatically (key encoding is application-level by convention).\n */\nexport class SchemaRegistrySerializer implements Serializer {\n readonly contentType: string;\n private readonly schemas: Record<string, SchemaSpec>;\n private readonly keySchemas: Record<string, SchemaSpec>;\n private readonly subjectStrategy: SubjectNameStrategy;\n private readonly recordName:\n | ((record: OutboxRecord, isKey: boolean) => string)\n | null;\n private readonly subjectFn:\n | ((topic: string, isKey?: boolean, record?: OutboxRecord) => string)\n | null;\n private readonly host: string | null;\n private readonly auth: SchemaRegistryAuth | null;\n private readonly autoRegister: boolean;\n // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.\n private readonly idCache = new Map<string, Promise<number>>();\n private registry: SchemaRegistryClient | null;\n\n constructor(opts: SchemaRegistrySerializerOptions) {\n if (!opts.registry && !opts.host) {\n throw new Error(\n \"SchemaRegistrySerializer requires either a `registry` client or a `host`.\",\n );\n }\n this.registry = opts.registry ?? null;\n this.host = opts.host ?? null;\n this.auth = opts.auth ?? null;\n this.schemas = opts.schemas ?? {};\n this.keySchemas = opts.keySchemas ?? {};\n this.subjectStrategy = opts.subjectStrategy ?? \"TopicNameStrategy\";\n this.recordName = opts.recordName ?? null;\n this.subjectFn = opts.subject ?? null;\n this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;\n this.autoRegister = opts.autoRegister ?? true;\n }\n\n async serialize(record: OutboxRecord): Promise<Buffer> {\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, false);\n const id = await this.schemaId(registry, subject, this.schemas[record.topic], false, record.topic);\n return registry.encode(id, record.payload);\n }\n\n /**\n * Avro-encode the record's KEY using the registered key schema. Returns\n * `null` when the record has no key (kafkajs/confluent treat null keys\n * as the producer-side \"no key\" signal).\n *\n * Not part of the core `Serializer` interface — callers wire it into\n * their publish path manually when they want Avro keys instead of raw\n * UTF-8 strings.\n */\n async serializeKey(record: OutboxRecord): Promise<Buffer | null> {\n if (record.key === null || record.key === undefined) return null;\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, true);\n const id = await this.schemaId(\n registry,\n subject,\n this.keySchemas[record.topic],\n true,\n record.topic,\n );\n return registry.encode(id, record.key);\n }\n\n /**\n * Resolve the subject for this (record, isKey) tuple. Order of\n * precedence: explicit `subject` function → `subjectStrategy` preset.\n */\n private resolveSubject(record: OutboxRecord, isKey: boolean): string {\n if (this.subjectFn) return this.subjectFn(record.topic, isKey, record);\n switch (this.subjectStrategy) {\n case \"TopicNameStrategy\":\n return `${record.topic}-${isKey ? \"key\" : \"value\"}`;\n case \"RecordNameStrategy\":\n return this.recordNameFor(record, isKey);\n case \"TopicRecordNameStrategy\":\n return `${record.topic}-${this.recordNameFor(record, isKey)}`;\n }\n }\n\n private recordNameFor(record: OutboxRecord, isKey: boolean): string {\n if (!this.recordName) {\n throw new Error(\n `SchemaRegistrySerializer: subjectStrategy \"${this.subjectStrategy}\" requires a \\`recordName\\` resolver.`,\n );\n }\n return this.recordName(record, isKey);\n }\n\n private schemaId(\n registry: SchemaRegistryClient,\n subject: string,\n spec: SchemaSpec | undefined,\n isKey: boolean,\n topic: string,\n ): Promise<number> {\n const cacheKey = `${topic}:${isKey ? \"key\" : \"value\"}`;\n const cached = this.idCache.get(cacheKey);\n if (cached) return cached;\n\n // autoRegister=false → ALWAYS resolve by latest; the local spec\n // (if any) is ignored. Matches Confluent's auto.register.schemas=false.\n const lookup =\n spec && this.autoRegister\n ? registry\n .register({ type: spec.type, schema: spec.schema }, { subject })\n .then((r) => r.id)\n : registry.getLatestSchemaId(subject);\n\n // Cache the in-flight promise so concurrent first calls don't double-register;\n // drop it on failure so a transient error can be retried.\n const guarded = lookup.catch((err) => {\n this.idCache.delete(cacheKey);\n throw err;\n });\n this.idCache.set(cacheKey, guarded);\n return guarded;\n }\n\n private async getRegistry(): Promise<SchemaRegistryClient> {\n if (this.registry) return this.registry;\n const mod = await importSchemaRegistry();\n const cfg: SchemaRegistryConstructorConfig = {\n host: this.host as string,\n };\n if (this.auth) {\n if (this.auth.type === \"basic\") {\n // Confluent SR client accepts `auth: { username, password }`\n // and the mappersmith basic-auth middleware builds the header.\n cfg.auth = {\n username: this.auth.username,\n password: this.auth.password,\n };\n } else {\n // Bearer: SR doesn't ship a built-in middleware. Inject our own\n // so every API call carries `Authorization: Bearer <token>`.\n cfg.middlewares = [bearerAuthMiddleware(this.auth.token)];\n }\n }\n this.registry = new mod.SchemaRegistry(cfg);\n return this.registry;\n }\n}\n\n/**\n * Mappersmith middleware shape — the structural subset the Confluent\n * SR client passes through. We don't depend on mappersmith types\n * directly so the package compiles without the optional peer installed.\n */\ninterface MmRequest {\n enhance(args: { headers?: Record<string, string> }): MmRequest;\n}\n\n/**\n * Build a mappersmith middleware that adds `Authorization: Bearer <token>`.\n *\n * Exported for testing only — not part of the public API surface. The\n * middleware resolves the token on EVERY request, so callable token\n * providers can rotate without re-constructing the serializer. Cache\n * inside your provider if rotation cost matters.\n */\nexport function bearerAuthMiddleware(\n token: string | (() => string | Promise<string>),\n) {\n return () => ({\n async prepareRequest(next: () => Promise<MmRequest>): Promise<MmRequest> {\n const request = await next();\n const value = typeof token === \"function\" ? await token() : token;\n return request.enhance({ headers: { Authorization: `Bearer ${value}` } });\n },\n });\n}\n\n/**\n * Structural shape the SR client's constructor accepts. Mirrors\n * `SchemaRegistryAPIClientArgs` from the upstream package without\n * importing the type directly (optional peer).\n */\ninterface SchemaRegistryConstructorConfig {\n host: string;\n auth?: { username: string; password: string };\n middlewares?: Array<() => unknown>;\n}\n\nasync function importSchemaRegistry(): Promise<{\n SchemaRegistry: new (\n cfg: SchemaRegistryConstructorConfig,\n ) => SchemaRegistryClient;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"@kafkajs/confluent-schema-registry\")) as any;\n } catch {\n throw new Error(\n 'SchemaRegistrySerializer with `host` needs the \"@kafkajs/confluent-schema-registry\" package. Run: npm i @kafkajs/confluent-schema-registry',\n );\n }\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -19,6 +19,31 @@ interface SchemaSpec {
19
19
  * Set `subject` (function form) to override entirely.
20
20
  */
21
21
  type SubjectNameStrategy = "TopicNameStrategy" | "RecordNameStrategy" | "TopicRecordNameStrategy";
22
+ /**
23
+ * Authentication for the Schema Registry HTTP API.
24
+ *
25
+ * - `"basic"` — HTTP Basic Auth. The shape Confluent Cloud and most
26
+ * commercial registries use; passed straight through to the underlying
27
+ * client's `auth` config.
28
+ * - `"bearer"` — `Authorization: Bearer <token>` header. The `token`
29
+ * field accepts either a static string OR a callable that resolves a
30
+ * fresh token on every request (cache inside your callable to amortise
31
+ * cost; we don't memoize for you, so OAuth refresh-loop logic lives
32
+ * on your side).
33
+ *
34
+ * mTLS for the registry connection itself is handled by Node's `tls`
35
+ * stack — supply a custom `https.Agent` via the upstream client's
36
+ * `agent` option (use the `registry` injection here and configure it
37
+ * yourself), separate from the broker TLS the publisher uses.
38
+ */
39
+ type SchemaRegistryAuth = {
40
+ type: "basic";
41
+ username: string;
42
+ password: string;
43
+ } | {
44
+ type: "bearer";
45
+ token: string | (() => string | Promise<string>);
46
+ };
22
47
  /**
23
48
  * The subset of a Confluent Schema Registry client this serializer uses. The
24
49
  * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.
@@ -40,6 +65,13 @@ interface SchemaRegistrySerializerOptions {
40
65
  registry?: SchemaRegistryClient;
41
66
  /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */
42
67
  host?: string;
68
+ /**
69
+ * Optional authentication for the Schema Registry HTTP API. See
70
+ * {@link SchemaRegistryAuth} for the two supported shapes. Ignored
71
+ * when `registry` is provided (configure auth on the injected client
72
+ * yourself in that case).
73
+ */
74
+ auth?: SchemaRegistryAuth;
43
75
  /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */
44
76
  schemas?: Record<string, SchemaSpec>;
45
77
  /**
@@ -103,6 +135,7 @@ declare class SchemaRegistrySerializer implements Serializer {
103
135
  private readonly recordName;
104
136
  private readonly subjectFn;
105
137
  private readonly host;
138
+ private readonly auth;
106
139
  private readonly autoRegister;
107
140
  private readonly idCache;
108
141
  private registry;
@@ -127,5 +160,26 @@ declare class SchemaRegistrySerializer implements Serializer {
127
160
  private schemaId;
128
161
  private getRegistry;
129
162
  }
163
+ /**
164
+ * Mappersmith middleware shape — the structural subset the Confluent
165
+ * SR client passes through. We don't depend on mappersmith types
166
+ * directly so the package compiles without the optional peer installed.
167
+ */
168
+ interface MmRequest {
169
+ enhance(args: {
170
+ headers?: Record<string, string>;
171
+ }): MmRequest;
172
+ }
173
+ /**
174
+ * Build a mappersmith middleware that adds `Authorization: Bearer <token>`.
175
+ *
176
+ * Exported for testing only — not part of the public API surface. The
177
+ * middleware resolves the token on EVERY request, so callable token
178
+ * providers can rotate without re-constructing the serializer. Cache
179
+ * inside your provider if rotation cost matters.
180
+ */
181
+ declare function bearerAuthMiddleware(token: string | (() => string | Promise<string>)): () => {
182
+ prepareRequest(next: () => Promise<MmRequest>): Promise<MmRequest>;
183
+ };
130
184
 
131
- export { type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType, type SubjectNameStrategy };
185
+ export { type SchemaRegistryAuth, type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType, type SubjectNameStrategy, bearerAuthMiddleware };
package/dist/index.d.ts CHANGED
@@ -19,6 +19,31 @@ interface SchemaSpec {
19
19
  * Set `subject` (function form) to override entirely.
20
20
  */
21
21
  type SubjectNameStrategy = "TopicNameStrategy" | "RecordNameStrategy" | "TopicRecordNameStrategy";
22
+ /**
23
+ * Authentication for the Schema Registry HTTP API.
24
+ *
25
+ * - `"basic"` — HTTP Basic Auth. The shape Confluent Cloud and most
26
+ * commercial registries use; passed straight through to the underlying
27
+ * client's `auth` config.
28
+ * - `"bearer"` — `Authorization: Bearer <token>` header. The `token`
29
+ * field accepts either a static string OR a callable that resolves a
30
+ * fresh token on every request (cache inside your callable to amortise
31
+ * cost; we don't memoize for you, so OAuth refresh-loop logic lives
32
+ * on your side).
33
+ *
34
+ * mTLS for the registry connection itself is handled by Node's `tls`
35
+ * stack — supply a custom `https.Agent` via the upstream client's
36
+ * `agent` option (use the `registry` injection here and configure it
37
+ * yourself), separate from the broker TLS the publisher uses.
38
+ */
39
+ type SchemaRegistryAuth = {
40
+ type: "basic";
41
+ username: string;
42
+ password: string;
43
+ } | {
44
+ type: "bearer";
45
+ token: string | (() => string | Promise<string>);
46
+ };
22
47
  /**
23
48
  * The subset of a Confluent Schema Registry client this serializer uses. The
24
49
  * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.
@@ -40,6 +65,13 @@ interface SchemaRegistrySerializerOptions {
40
65
  registry?: SchemaRegistryClient;
41
66
  /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */
42
67
  host?: string;
68
+ /**
69
+ * Optional authentication for the Schema Registry HTTP API. See
70
+ * {@link SchemaRegistryAuth} for the two supported shapes. Ignored
71
+ * when `registry` is provided (configure auth on the injected client
72
+ * yourself in that case).
73
+ */
74
+ auth?: SchemaRegistryAuth;
43
75
  /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */
44
76
  schemas?: Record<string, SchemaSpec>;
45
77
  /**
@@ -103,6 +135,7 @@ declare class SchemaRegistrySerializer implements Serializer {
103
135
  private readonly recordName;
104
136
  private readonly subjectFn;
105
137
  private readonly host;
138
+ private readonly auth;
106
139
  private readonly autoRegister;
107
140
  private readonly idCache;
108
141
  private registry;
@@ -127,5 +160,26 @@ declare class SchemaRegistrySerializer implements Serializer {
127
160
  private schemaId;
128
161
  private getRegistry;
129
162
  }
163
+ /**
164
+ * Mappersmith middleware shape — the structural subset the Confluent
165
+ * SR client passes through. We don't depend on mappersmith types
166
+ * directly so the package compiles without the optional peer installed.
167
+ */
168
+ interface MmRequest {
169
+ enhance(args: {
170
+ headers?: Record<string, string>;
171
+ }): MmRequest;
172
+ }
173
+ /**
174
+ * Build a mappersmith middleware that adds `Authorization: Bearer <token>`.
175
+ *
176
+ * Exported for testing only — not part of the public API surface. The
177
+ * middleware resolves the token on EVERY request, so callable token
178
+ * providers can rotate without re-constructing the serializer. Cache
179
+ * inside your provider if rotation cost matters.
180
+ */
181
+ declare function bearerAuthMiddleware(token: string | (() => string | Promise<string>)): () => {
182
+ prepareRequest(next: () => Promise<MmRequest>): Promise<MmRequest>;
183
+ };
130
184
 
131
- export { type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType, type SubjectNameStrategy };
185
+ export { type SchemaRegistryAuth, type SchemaRegistryClient, SchemaRegistrySerializer, type SchemaRegistrySerializerOptions, type SchemaSpec, type SchemaType, type SubjectNameStrategy, bearerAuthMiddleware };
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ var SchemaRegistrySerializer = class {
8
8
  recordName;
9
9
  subjectFn;
10
10
  host;
11
+ auth;
11
12
  autoRegister;
12
13
  // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.
13
14
  idCache = /* @__PURE__ */ new Map();
@@ -20,6 +21,7 @@ var SchemaRegistrySerializer = class {
20
21
  }
21
22
  this.registry = opts.registry ?? null;
22
23
  this.host = opts.host ?? null;
24
+ this.auth = opts.auth ?? null;
23
25
  this.schemas = opts.schemas ?? {};
24
26
  this.keySchemas = opts.keySchemas ?? {};
25
27
  this.subjectStrategy = opts.subjectStrategy ?? "TopicNameStrategy";
@@ -94,10 +96,32 @@ var SchemaRegistrySerializer = class {
94
96
  async getRegistry() {
95
97
  if (this.registry) return this.registry;
96
98
  const mod = await importSchemaRegistry();
97
- this.registry = new mod.SchemaRegistry({ host: this.host });
99
+ const cfg = {
100
+ host: this.host
101
+ };
102
+ if (this.auth) {
103
+ if (this.auth.type === "basic") {
104
+ cfg.auth = {
105
+ username: this.auth.username,
106
+ password: this.auth.password
107
+ };
108
+ } else {
109
+ cfg.middlewares = [bearerAuthMiddleware(this.auth.token)];
110
+ }
111
+ }
112
+ this.registry = new mod.SchemaRegistry(cfg);
98
113
  return this.registry;
99
114
  }
100
115
  };
116
+ function bearerAuthMiddleware(token) {
117
+ return () => ({
118
+ async prepareRequest(next) {
119
+ const request = await next();
120
+ const value = typeof token === "function" ? await token() : token;
121
+ return request.enhance({ headers: { Authorization: `Bearer ${value}` } });
122
+ }
123
+ });
124
+ }
101
125
  async function importSchemaRegistry() {
102
126
  try {
103
127
  return await import("@kafkajs/confluent-schema-registry");
@@ -108,6 +132,7 @@ async function importSchemaRegistry() {
108
132
  }
109
133
  }
110
134
  export {
111
- SchemaRegistrySerializer
135
+ SchemaRegistrySerializer,
136
+ bearerAuthMiddleware
112
137
  };
113
138
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/serializer.ts"],"sourcesContent":["import type { OutboxRecord, Serializer } from \"@eventferry/core\";\n\nexport type SchemaType = \"AVRO\" | \"PROTOBUF\" | \"JSON\";\n\nexport interface SchemaSpec {\n type: SchemaType;\n /** Schema definition string (avsc JSON / .proto / JSON Schema). */\n schema: string;\n}\n\n/**\n * Subject naming strategy. Mirrors Confluent's three built-ins:\n *\n * - `\"TopicNameStrategy\"` (default) — `${topic}-value` / `${topic}-key`.\n * The conventional default; one schema per (topic, isKey) tuple.\n * - `\"RecordNameStrategy\"` — `${recordName}`. Same record type can flow\n * on multiple topics. Requires a `recordName` resolver.\n * - `\"TopicRecordNameStrategy\"` — `${topic}-${recordName}`. Multiple record\n * types per topic. Requires a `recordName` resolver.\n *\n * Set `subject` (function form) to override entirely.\n */\nexport type SubjectNameStrategy =\n | \"TopicNameStrategy\"\n | \"RecordNameStrategy\"\n | \"TopicRecordNameStrategy\";\n\n/**\n * The subset of a Confluent Schema Registry client this serializer uses. The\n * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.\n */\nexport interface SchemaRegistryClient {\n register(\n schema: { type: string; schema: string },\n opts?: { subject: string },\n ): Promise<{ id: number }>;\n getLatestSchemaId(subject: string): Promise<number>;\n encode(registryId: number, payload: unknown): Promise<Buffer>;\n}\n\nexport interface SchemaRegistrySerializerOptions {\n /** Inject a ready client (tests, custom config). */\n registry?: SchemaRegistryClient;\n /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */\n host?: string;\n /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */\n schemas?: Record<string, SchemaSpec>;\n /**\n * Per-topic KEY schema. When set, `serializeKey(record)` Avro-encodes\n * the record key for the matching topic. Topics omitted here fall back\n * to the subject's latest (or, with `autoRegister: false`, ALWAYS the\n * subject's latest).\n */\n keySchemas?: Record<string, SchemaSpec>;\n /**\n * Subject naming strategy preset. Default `\"TopicNameStrategy\"`.\n * Setting `subject` (function) overrides this entirely.\n */\n subjectStrategy?: SubjectNameStrategy;\n /**\n * Resolve the schema's record name (used by `RecordNameStrategy` and\n * `TopicRecordNameStrategy`). REQUIRED when `subjectStrategy` is set to\n * one of those — throws on first serialize if absent.\n *\n * Typical implementation: read `${namespace}.${name}` from the avsc you\n * already supply via `schemas` / `keySchemas`.\n */\n recordName?: (record: OutboxRecord, isKey: boolean) => string;\n /**\n * Custom subject function — overrides BOTH `subjectStrategy` and\n * `recordName`. Receives `(topic, isKey, record)` for full flexibility.\n *\n * Backwards-compatible with the single-argument legacy form\n * `(topic) => string` — extra args are ignored by JavaScript.\n */\n subject?: (\n topic: string,\n isKey?: boolean,\n record?: OutboxRecord,\n ) => string;\n /** content-type header value. Default \"application/vnd.confluent.avro\". */\n contentType?: string;\n /**\n * Auto-register schemas when one is supplied via `schemas` / `keySchemas`.\n * Default `true` — matches Confluent client behavior.\n *\n * Set to `false` for production clusters where schemas are managed\n * out-of-band (Confluent Cloud, regulated environments). With\n * autoRegister off, the serializer ALWAYS resolves by `getLatestSchemaId`\n * on the computed subject — and the locally-supplied schema bytes are\n * ignored.\n */\n autoRegister?: boolean;\n}\n\nconst DEFAULT_CONTENT_TYPE = \"application/vnd.confluent.avro\";\n\n/**\n * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry\n * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s\n * `serializer` option. The schema id per (topic, isKey) tuple is resolved once\n * and cached.\n *\n * Also exposes `serializeKey(record)` for users who want Avro-encoded message\n * keys — call it manually when building the publish path; the relay does NOT\n * call it automatically (key encoding is application-level by convention).\n */\nexport class SchemaRegistrySerializer implements Serializer {\n readonly contentType: string;\n private readonly schemas: Record<string, SchemaSpec>;\n private readonly keySchemas: Record<string, SchemaSpec>;\n private readonly subjectStrategy: SubjectNameStrategy;\n private readonly recordName:\n | ((record: OutboxRecord, isKey: boolean) => string)\n | null;\n private readonly subjectFn:\n | ((topic: string, isKey?: boolean, record?: OutboxRecord) => string)\n | null;\n private readonly host: string | null;\n private readonly autoRegister: boolean;\n // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.\n private readonly idCache = new Map<string, Promise<number>>();\n private registry: SchemaRegistryClient | null;\n\n constructor(opts: SchemaRegistrySerializerOptions) {\n if (!opts.registry && !opts.host) {\n throw new Error(\n \"SchemaRegistrySerializer requires either a `registry` client or a `host`.\",\n );\n }\n this.registry = opts.registry ?? null;\n this.host = opts.host ?? null;\n this.schemas = opts.schemas ?? {};\n this.keySchemas = opts.keySchemas ?? {};\n this.subjectStrategy = opts.subjectStrategy ?? \"TopicNameStrategy\";\n this.recordName = opts.recordName ?? null;\n this.subjectFn = opts.subject ?? null;\n this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;\n this.autoRegister = opts.autoRegister ?? true;\n }\n\n async serialize(record: OutboxRecord): Promise<Buffer> {\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, false);\n const id = await this.schemaId(registry, subject, this.schemas[record.topic], false, record.topic);\n return registry.encode(id, record.payload);\n }\n\n /**\n * Avro-encode the record's KEY using the registered key schema. Returns\n * `null` when the record has no key (kafkajs/confluent treat null keys\n * as the producer-side \"no key\" signal).\n *\n * Not part of the core `Serializer` interface — callers wire it into\n * their publish path manually when they want Avro keys instead of raw\n * UTF-8 strings.\n */\n async serializeKey(record: OutboxRecord): Promise<Buffer | null> {\n if (record.key === null || record.key === undefined) return null;\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, true);\n const id = await this.schemaId(\n registry,\n subject,\n this.keySchemas[record.topic],\n true,\n record.topic,\n );\n return registry.encode(id, record.key);\n }\n\n /**\n * Resolve the subject for this (record, isKey) tuple. Order of\n * precedence: explicit `subject` function → `subjectStrategy` preset.\n */\n private resolveSubject(record: OutboxRecord, isKey: boolean): string {\n if (this.subjectFn) return this.subjectFn(record.topic, isKey, record);\n switch (this.subjectStrategy) {\n case \"TopicNameStrategy\":\n return `${record.topic}-${isKey ? \"key\" : \"value\"}`;\n case \"RecordNameStrategy\":\n return this.recordNameFor(record, isKey);\n case \"TopicRecordNameStrategy\":\n return `${record.topic}-${this.recordNameFor(record, isKey)}`;\n }\n }\n\n private recordNameFor(record: OutboxRecord, isKey: boolean): string {\n if (!this.recordName) {\n throw new Error(\n `SchemaRegistrySerializer: subjectStrategy \"${this.subjectStrategy}\" requires a \\`recordName\\` resolver.`,\n );\n }\n return this.recordName(record, isKey);\n }\n\n private schemaId(\n registry: SchemaRegistryClient,\n subject: string,\n spec: SchemaSpec | undefined,\n isKey: boolean,\n topic: string,\n ): Promise<number> {\n const cacheKey = `${topic}:${isKey ? \"key\" : \"value\"}`;\n const cached = this.idCache.get(cacheKey);\n if (cached) return cached;\n\n // autoRegister=false → ALWAYS resolve by latest; the local spec\n // (if any) is ignored. Matches Confluent's auto.register.schemas=false.\n const lookup =\n spec && this.autoRegister\n ? registry\n .register({ type: spec.type, schema: spec.schema }, { subject })\n .then((r) => r.id)\n : registry.getLatestSchemaId(subject);\n\n // Cache the in-flight promise so concurrent first calls don't double-register;\n // drop it on failure so a transient error can be retried.\n const guarded = lookup.catch((err) => {\n this.idCache.delete(cacheKey);\n throw err;\n });\n this.idCache.set(cacheKey, guarded);\n return guarded;\n }\n\n private async getRegistry(): Promise<SchemaRegistryClient> {\n if (this.registry) return this.registry;\n const mod = await importSchemaRegistry();\n this.registry = new mod.SchemaRegistry({ host: this.host as string });\n return this.registry;\n }\n}\n\nasync function importSchemaRegistry(): Promise<{\n SchemaRegistry: new (cfg: { host: string }) => SchemaRegistryClient;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"@kafkajs/confluent-schema-registry\")) as any;\n } catch {\n throw new Error(\n 'SchemaRegistrySerializer with `host` needs the \"@kafkajs/confluent-schema-registry\" package. Run: npm i @kafkajs/confluent-schema-registry',\n );\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/serializer.ts"],"sourcesContent":["import type { OutboxRecord, Serializer } from \"@eventferry/core\";\n\nexport type SchemaType = \"AVRO\" | \"PROTOBUF\" | \"JSON\";\n\nexport interface SchemaSpec {\n type: SchemaType;\n /** Schema definition string (avsc JSON / .proto / JSON Schema). */\n schema: string;\n}\n\n/**\n * Subject naming strategy. Mirrors Confluent's three built-ins:\n *\n * - `\"TopicNameStrategy\"` (default) — `${topic}-value` / `${topic}-key`.\n * The conventional default; one schema per (topic, isKey) tuple.\n * - `\"RecordNameStrategy\"` — `${recordName}`. Same record type can flow\n * on multiple topics. Requires a `recordName` resolver.\n * - `\"TopicRecordNameStrategy\"` — `${topic}-${recordName}`. Multiple record\n * types per topic. Requires a `recordName` resolver.\n *\n * Set `subject` (function form) to override entirely.\n */\nexport type SubjectNameStrategy =\n | \"TopicNameStrategy\"\n | \"RecordNameStrategy\"\n | \"TopicRecordNameStrategy\";\n\n/**\n * Authentication for the Schema Registry HTTP API.\n *\n * - `\"basic\"` — HTTP Basic Auth. The shape Confluent Cloud and most\n * commercial registries use; passed straight through to the underlying\n * client's `auth` config.\n * - `\"bearer\"` — `Authorization: Bearer <token>` header. The `token`\n * field accepts either a static string OR a callable that resolves a\n * fresh token on every request (cache inside your callable to amortise\n * cost; we don't memoize for you, so OAuth refresh-loop logic lives\n * on your side).\n *\n * mTLS for the registry connection itself is handled by Node's `tls`\n * stack — supply a custom `https.Agent` via the upstream client's\n * `agent` option (use the `registry` injection here and configure it\n * yourself), separate from the broker TLS the publisher uses.\n */\nexport type SchemaRegistryAuth =\n | { type: \"basic\"; username: string; password: string }\n | {\n type: \"bearer\";\n token: string | (() => string | Promise<string>);\n };\n\n/**\n * The subset of a Confluent Schema Registry client this serializer uses. The\n * `@kafkajs/confluent-schema-registry` `SchemaRegistry` satisfies it structurally.\n */\nexport interface SchemaRegistryClient {\n register(\n schema: { type: string; schema: string },\n opts?: { subject: string },\n ): Promise<{ id: number }>;\n getLatestSchemaId(subject: string): Promise<number>;\n encode(registryId: number, payload: unknown): Promise<Buffer>;\n}\n\nexport interface SchemaRegistrySerializerOptions {\n /** Inject a ready client (tests, custom config). */\n registry?: SchemaRegistryClient;\n /** Or construct one from a host (requires @kafkajs/confluent-schema-registry). */\n host?: string;\n /**\n * Optional authentication for the Schema Registry HTTP API. See\n * {@link SchemaRegistryAuth} for the two supported shapes. Ignored\n * when `registry` is provided (configure auth on the injected client\n * yourself in that case).\n */\n auth?: SchemaRegistryAuth;\n /** Per-topic VALUE schema to register. Topics omitted here use the subject's latest. */\n schemas?: Record<string, SchemaSpec>;\n /**\n * Per-topic KEY schema. When set, `serializeKey(record)` Avro-encodes\n * the record key for the matching topic. Topics omitted here fall back\n * to the subject's latest (or, with `autoRegister: false`, ALWAYS the\n * subject's latest).\n */\n keySchemas?: Record<string, SchemaSpec>;\n /**\n * Subject naming strategy preset. Default `\"TopicNameStrategy\"`.\n * Setting `subject` (function) overrides this entirely.\n */\n subjectStrategy?: SubjectNameStrategy;\n /**\n * Resolve the schema's record name (used by `RecordNameStrategy` and\n * `TopicRecordNameStrategy`). REQUIRED when `subjectStrategy` is set to\n * one of those — throws on first serialize if absent.\n *\n * Typical implementation: read `${namespace}.${name}` from the avsc you\n * already supply via `schemas` / `keySchemas`.\n */\n recordName?: (record: OutboxRecord, isKey: boolean) => string;\n /**\n * Custom subject function — overrides BOTH `subjectStrategy` and\n * `recordName`. Receives `(topic, isKey, record)` for full flexibility.\n *\n * Backwards-compatible with the single-argument legacy form\n * `(topic) => string` — extra args are ignored by JavaScript.\n */\n subject?: (\n topic: string,\n isKey?: boolean,\n record?: OutboxRecord,\n ) => string;\n /** content-type header value. Default \"application/vnd.confluent.avro\". */\n contentType?: string;\n /**\n * Auto-register schemas when one is supplied via `schemas` / `keySchemas`.\n * Default `true` — matches Confluent client behavior.\n *\n * Set to `false` for production clusters where schemas are managed\n * out-of-band (Confluent Cloud, regulated environments). With\n * autoRegister off, the serializer ALWAYS resolves by `getLatestSchemaId`\n * on the computed subject — and the locally-supplied schema bytes are\n * ignored.\n */\n autoRegister?: boolean;\n}\n\nconst DEFAULT_CONTENT_TYPE = \"application/vnd.confluent.avro\";\n\n/**\n * A core {@link Serializer} that encodes payloads with a Confluent Schema Registry\n * (Avro / Protobuf / JSON Schema). Drop it into `Relay`/`PostgresStreamingRelay`'s\n * `serializer` option. The schema id per (topic, isKey) tuple is resolved once\n * and cached.\n *\n * Also exposes `serializeKey(record)` for users who want Avro-encoded message\n * keys — call it manually when building the publish path; the relay does NOT\n * call it automatically (key encoding is application-level by convention).\n */\nexport class SchemaRegistrySerializer implements Serializer {\n readonly contentType: string;\n private readonly schemas: Record<string, SchemaSpec>;\n private readonly keySchemas: Record<string, SchemaSpec>;\n private readonly subjectStrategy: SubjectNameStrategy;\n private readonly recordName:\n | ((record: OutboxRecord, isKey: boolean) => string)\n | null;\n private readonly subjectFn:\n | ((topic: string, isKey?: boolean, record?: OutboxRecord) => string)\n | null;\n private readonly host: string | null;\n private readonly auth: SchemaRegistryAuth | null;\n private readonly autoRegister: boolean;\n // Keyed by `${topic}:${isKey}` to keep value- and key-subject ids distinct.\n private readonly idCache = new Map<string, Promise<number>>();\n private registry: SchemaRegistryClient | null;\n\n constructor(opts: SchemaRegistrySerializerOptions) {\n if (!opts.registry && !opts.host) {\n throw new Error(\n \"SchemaRegistrySerializer requires either a `registry` client or a `host`.\",\n );\n }\n this.registry = opts.registry ?? null;\n this.host = opts.host ?? null;\n this.auth = opts.auth ?? null;\n this.schemas = opts.schemas ?? {};\n this.keySchemas = opts.keySchemas ?? {};\n this.subjectStrategy = opts.subjectStrategy ?? \"TopicNameStrategy\";\n this.recordName = opts.recordName ?? null;\n this.subjectFn = opts.subject ?? null;\n this.contentType = opts.contentType ?? DEFAULT_CONTENT_TYPE;\n this.autoRegister = opts.autoRegister ?? true;\n }\n\n async serialize(record: OutboxRecord): Promise<Buffer> {\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, false);\n const id = await this.schemaId(registry, subject, this.schemas[record.topic], false, record.topic);\n return registry.encode(id, record.payload);\n }\n\n /**\n * Avro-encode the record's KEY using the registered key schema. Returns\n * `null` when the record has no key (kafkajs/confluent treat null keys\n * as the producer-side \"no key\" signal).\n *\n * Not part of the core `Serializer` interface — callers wire it into\n * their publish path manually when they want Avro keys instead of raw\n * UTF-8 strings.\n */\n async serializeKey(record: OutboxRecord): Promise<Buffer | null> {\n if (record.key === null || record.key === undefined) return null;\n const registry = await this.getRegistry();\n const subject = this.resolveSubject(record, true);\n const id = await this.schemaId(\n registry,\n subject,\n this.keySchemas[record.topic],\n true,\n record.topic,\n );\n return registry.encode(id, record.key);\n }\n\n /**\n * Resolve the subject for this (record, isKey) tuple. Order of\n * precedence: explicit `subject` function → `subjectStrategy` preset.\n */\n private resolveSubject(record: OutboxRecord, isKey: boolean): string {\n if (this.subjectFn) return this.subjectFn(record.topic, isKey, record);\n switch (this.subjectStrategy) {\n case \"TopicNameStrategy\":\n return `${record.topic}-${isKey ? \"key\" : \"value\"}`;\n case \"RecordNameStrategy\":\n return this.recordNameFor(record, isKey);\n case \"TopicRecordNameStrategy\":\n return `${record.topic}-${this.recordNameFor(record, isKey)}`;\n }\n }\n\n private recordNameFor(record: OutboxRecord, isKey: boolean): string {\n if (!this.recordName) {\n throw new Error(\n `SchemaRegistrySerializer: subjectStrategy \"${this.subjectStrategy}\" requires a \\`recordName\\` resolver.`,\n );\n }\n return this.recordName(record, isKey);\n }\n\n private schemaId(\n registry: SchemaRegistryClient,\n subject: string,\n spec: SchemaSpec | undefined,\n isKey: boolean,\n topic: string,\n ): Promise<number> {\n const cacheKey = `${topic}:${isKey ? \"key\" : \"value\"}`;\n const cached = this.idCache.get(cacheKey);\n if (cached) return cached;\n\n // autoRegister=false → ALWAYS resolve by latest; the local spec\n // (if any) is ignored. Matches Confluent's auto.register.schemas=false.\n const lookup =\n spec && this.autoRegister\n ? registry\n .register({ type: spec.type, schema: spec.schema }, { subject })\n .then((r) => r.id)\n : registry.getLatestSchemaId(subject);\n\n // Cache the in-flight promise so concurrent first calls don't double-register;\n // drop it on failure so a transient error can be retried.\n const guarded = lookup.catch((err) => {\n this.idCache.delete(cacheKey);\n throw err;\n });\n this.idCache.set(cacheKey, guarded);\n return guarded;\n }\n\n private async getRegistry(): Promise<SchemaRegistryClient> {\n if (this.registry) return this.registry;\n const mod = await importSchemaRegistry();\n const cfg: SchemaRegistryConstructorConfig = {\n host: this.host as string,\n };\n if (this.auth) {\n if (this.auth.type === \"basic\") {\n // Confluent SR client accepts `auth: { username, password }`\n // and the mappersmith basic-auth middleware builds the header.\n cfg.auth = {\n username: this.auth.username,\n password: this.auth.password,\n };\n } else {\n // Bearer: SR doesn't ship a built-in middleware. Inject our own\n // so every API call carries `Authorization: Bearer <token>`.\n cfg.middlewares = [bearerAuthMiddleware(this.auth.token)];\n }\n }\n this.registry = new mod.SchemaRegistry(cfg);\n return this.registry;\n }\n}\n\n/**\n * Mappersmith middleware shape — the structural subset the Confluent\n * SR client passes through. We don't depend on mappersmith types\n * directly so the package compiles without the optional peer installed.\n */\ninterface MmRequest {\n enhance(args: { headers?: Record<string, string> }): MmRequest;\n}\n\n/**\n * Build a mappersmith middleware that adds `Authorization: Bearer <token>`.\n *\n * Exported for testing only — not part of the public API surface. The\n * middleware resolves the token on EVERY request, so callable token\n * providers can rotate without re-constructing the serializer. Cache\n * inside your provider if rotation cost matters.\n */\nexport function bearerAuthMiddleware(\n token: string | (() => string | Promise<string>),\n) {\n return () => ({\n async prepareRequest(next: () => Promise<MmRequest>): Promise<MmRequest> {\n const request = await next();\n const value = typeof token === \"function\" ? await token() : token;\n return request.enhance({ headers: { Authorization: `Bearer ${value}` } });\n },\n });\n}\n\n/**\n * Structural shape the SR client's constructor accepts. Mirrors\n * `SchemaRegistryAPIClientArgs` from the upstream package without\n * importing the type directly (optional peer).\n */\ninterface SchemaRegistryConstructorConfig {\n host: string;\n auth?: { username: string; password: string };\n middlewares?: Array<() => unknown>;\n}\n\nasync function importSchemaRegistry(): Promise<{\n SchemaRegistry: new (\n cfg: SchemaRegistryConstructorConfig,\n ) => SchemaRegistryClient;\n}> {\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return (await import(\"@kafkajs/confluent-schema-registry\")) as any;\n } catch {\n throw new Error(\n 'SchemaRegistrySerializer with `host` needs the \"@kafkajs/confluent-schema-registry\" package. Run: npm i @kafkajs/confluent-schema-registry',\n );\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventferry/schema-registry",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "Confluent Schema Registry serializer for @eventferry (Avro/Protobuf/JSON Schema)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -17,6 +17,9 @@
17
17
  "dist",
18
18
  "CHANGELOG.md"
19
19
  ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
20
23
  "keywords": [
21
24
  "outbox",
22
25
  "transactional-outbox",
@@ -45,7 +48,7 @@
45
48
  "node": ">=18"
46
49
  },
47
50
  "dependencies": {
48
- "@eventferry/core": "3.3.1"
51
+ "@eventferry/core": "3.4.0"
49
52
  },
50
53
  "peerDependencies": {
51
54
  "@kafkajs/confluent-schema-registry": "^3.0.0"
@@ -59,8 +62,8 @@
59
62
  "@kafkajs/confluent-schema-registry": "^3.0.0",
60
63
  "tsup": "^8.3.5",
61
64
  "typescript": "^5.7.2",
62
- "vitest": "^2.1.8",
63
- "@eventferry/core": "3.3.1"
65
+ "vitest": "^3.2.6",
66
+ "@eventferry/core": "3.4.0"
64
67
  },
65
68
  "scripts": {
66
69
  "build": "tsup",