@streamotter/gateway 0.1.0-rc.1
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/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/internals.d.ts +6 -0
- package/dist/internals.d.ts.map +1 -0
- package/dist/internals.js +6 -0
- package/dist/internals.js.map +1 -0
- package/dist/management/index.d.ts +26 -0
- package/dist/management/index.d.ts.map +1 -0
- package/dist/management/index.js +354 -0
- package/dist/management/index.js.map +1 -0
- package/dist/runtime/budget.d.ts +24 -0
- package/dist/runtime/budget.d.ts.map +1 -0
- package/dist/runtime/budget.js +56 -0
- package/dist/runtime/budget.js.map +1 -0
- package/dist/runtime/core.d.ts +67 -0
- package/dist/runtime/core.d.ts.map +1 -0
- package/dist/runtime/core.js +34 -0
- package/dist/runtime/core.js.map +1 -0
- package/dist/runtime/gateway.d.ts +117 -0
- package/dist/runtime/gateway.d.ts.map +1 -0
- package/dist/runtime/gateway.js +881 -0
- package/dist/runtime/gateway.js.map +1 -0
- package/dist/runtime/identity.d.ts +28 -0
- package/dist/runtime/identity.d.ts.map +1 -0
- package/dist/runtime/identity.js +92 -0
- package/dist/runtime/identity.js.map +1 -0
- package/dist/runtime/session.d.ts +49 -0
- package/dist/runtime/session.d.ts.map +1 -0
- package/dist/runtime/session.js +299 -0
- package/dist/runtime/session.js.map +1 -0
- package/dist/runtime/subscription.d.ts +65 -0
- package/dist/runtime/subscription.d.ts.map +1 -0
- package/dist/runtime/subscription.js +482 -0
- package/dist/runtime/subscription.js.map +1 -0
- package/dist/runtime/traces.d.ts +26 -0
- package/dist/runtime/traces.d.ts.map +1 -0
- package/dist/runtime/traces.js +98 -0
- package/dist/runtime/traces.js.map +1 -0
- package/dist/runtime/util.d.ts +50 -0
- package/dist/runtime/util.d.ts.map +1 -0
- package/dist/runtime/util.js +148 -0
- package/dist/runtime/util.js.map +1 -0
- package/dist/sources/fixture.d.ts +27 -0
- package/dist/sources/fixture.d.ts.map +1 -0
- package/dist/sources/fixture.js +89 -0
- package/dist/sources/fixture.js.map +1 -0
- package/dist/sources/kafka.d.ts +63 -0
- package/dist/sources/kafka.d.ts.map +1 -0
- package/dist/sources/kafka.js +418 -0
- package/dist/sources/kafka.js.map +1 -0
- package/dist/sources/kafkajs-patch.d.ts +11 -0
- package/dist/sources/kafkajs-patch.d.ts.map +1 -0
- package/dist/sources/kafkajs-patch.js +34 -0
- package/dist/sources/kafkajs-patch.js.map +1 -0
- package/dist/sources/types.d.ts +46 -0
- package/dist/sources/types.d.ts.map +1 -0
- package/dist/sources/types.js +2 -0
- package/dist/sources/types.js.map +1 -0
- package/dist/transport/socketio.d.ts +44 -0
- package/dist/transport/socketio.d.ts.map +1 -0
- package/dist/transport/socketio.js +80 -0
- package/dist/transport/socketio.js.map +1 -0
- package/dist/transport/types.d.ts +10 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/types.js +2 -0
- package/dist/transport/types.js.map +1 -0
- package/package.json +61 -0
- package/src/index.ts +20 -0
- package/src/internals.ts +5 -0
- package/src/management/index.ts +371 -0
- package/src/runtime/budget.ts +60 -0
- package/src/runtime/core.ts +101 -0
- package/src/runtime/gateway.ts +892 -0
- package/src/runtime/identity.ts +99 -0
- package/src/runtime/session.ts +329 -0
- package/src/runtime/subscription.ts +531 -0
- package/src/runtime/traces.ts +102 -0
- package/src/runtime/util.ts +157 -0
- package/src/sources/fixture.ts +95 -0
- package/src/sources/kafka.ts +440 -0
- package/src/sources/kafkajs-patch.ts +41 -0
- package/src/sources/types.ts +42 -0
- package/src/transport/socketio.ts +125 -0
- package/src/transport/types.ts +10 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { canonicalJson, StreamOtterError, type Awaitable, type GatewayLogger, type Json } from "@streamotter/contracts";
|
|
3
|
+
|
|
4
|
+
export type HandlerOutcome<T> =
|
|
5
|
+
| { kind: "ok"; value: T }
|
|
6
|
+
| { kind: "timeout" }
|
|
7
|
+
| { kind: "aborted" }
|
|
8
|
+
| { kind: "error"; error: unknown };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Invokes an application handler with a deadline and an AbortSignal linked to
|
|
12
|
+
* `parent`. Late results after timeout or abort are ignored.
|
|
13
|
+
*/
|
|
14
|
+
export async function invokeHandler<T>(
|
|
15
|
+
handler: (context: { signal: AbortSignal; requestId: string }) => Awaitable<T>,
|
|
16
|
+
options: { timeoutMs: number; requestId: string; parent?: AbortSignal | undefined }
|
|
17
|
+
): Promise<HandlerOutcome<T>> {
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const { parent } = options;
|
|
20
|
+
if (parent?.aborted) return { kind: "aborted" };
|
|
21
|
+
let settle: ((outcome: HandlerOutcome<T>) => void) | undefined;
|
|
22
|
+
const race = new Promise<HandlerOutcome<T>>(resolve => { settle = resolve; });
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
controller.abort(new StreamOtterError("TIMEOUT", { requestId: options.requestId }));
|
|
25
|
+
settle?.({ kind: "timeout" });
|
|
26
|
+
}, options.timeoutMs);
|
|
27
|
+
const onParentAbort = () => {
|
|
28
|
+
controller.abort(new StreamOtterError("CANCELLED", { requestId: options.requestId }));
|
|
29
|
+
settle?.({ kind: "aborted" });
|
|
30
|
+
};
|
|
31
|
+
parent?.addEventListener("abort", onParentAbort, { once: true });
|
|
32
|
+
try {
|
|
33
|
+
Promise.resolve()
|
|
34
|
+
.then(() => handler({ signal: controller.signal, requestId: options.requestId }))
|
|
35
|
+
.then(value => settle?.({ kind: "ok", value }), (error: unknown) => settle?.({ kind: "error", error }));
|
|
36
|
+
return await race;
|
|
37
|
+
} finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
parent?.removeEventListener("abort", onParentAbort);
|
|
40
|
+
settle = undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sha256Hex(value: unknown): string {
|
|
45
|
+
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function newId(): string {
|
|
49
|
+
return randomUUID();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function nowIso(): string {
|
|
53
|
+
return new Date().toISOString();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Describes an application error for operator logs without its stack. */
|
|
57
|
+
export function describeError(error: unknown): Json {
|
|
58
|
+
if (error instanceof Error) return { name: error.name, message: error.message.slice(0, 200) };
|
|
59
|
+
return { name: typeof error };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Structured console logger; the default operator diagnostics sink. */
|
|
63
|
+
export function consoleLogger(minimum: "info" | "warn" | "error" = "info"): GatewayLogger {
|
|
64
|
+
const order = { info: 0, warn: 1, error: 2 } as const;
|
|
65
|
+
const write = (level: keyof typeof order, message: string, fields?: Readonly<Record<string, Json>>) => {
|
|
66
|
+
if (order[level] < order[minimum]) return;
|
|
67
|
+
const line = JSON.stringify({ at: nowIso(), level, message, ...(fields ?? {}) });
|
|
68
|
+
if (level === "info") console.log(line); else console.error(line);
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
info: (message, fields) => write("info", message, fields),
|
|
72
|
+
warn: (message, fields) => write("warn", message, fields),
|
|
73
|
+
error: (message, fields) => write("error", message, fields)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const silentLogger: GatewayLogger = { info() {}, warn() {}, error() {} };
|
|
78
|
+
|
|
79
|
+
/** setTimeout that tolerates delays beyond the 32-bit timer limit. */
|
|
80
|
+
export function setLongTimeout(callback: () => void, delayMs: number): { clear(): void } {
|
|
81
|
+
const MAX = 2_147_000_000;
|
|
82
|
+
let handle: NodeJS.Timeout;
|
|
83
|
+
const schedule = (remaining: number) => {
|
|
84
|
+
handle = setTimeout(() => {
|
|
85
|
+
if (remaining > MAX) schedule(remaining - MAX);
|
|
86
|
+
else callback();
|
|
87
|
+
}, Math.max(0, Math.min(remaining, MAX)));
|
|
88
|
+
handle.unref?.();
|
|
89
|
+
};
|
|
90
|
+
schedule(delayMs);
|
|
91
|
+
return { clear: () => clearTimeout(handle) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Counting semaphore whose waits honor a deadline and an abort signal. */
|
|
95
|
+
export class Semaphore {
|
|
96
|
+
#available: number;
|
|
97
|
+
readonly #waiters: { resolve: (acquired: boolean) => void }[] = [];
|
|
98
|
+
|
|
99
|
+
constructor(permits: number) {
|
|
100
|
+
this.#available = permits;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
acquire(signal: AbortSignal, deadline: number): Promise<boolean> {
|
|
104
|
+
if (signal.aborted || Date.now() >= deadline) return Promise.resolve(false);
|
|
105
|
+
if (this.#available > 0) {
|
|
106
|
+
this.#available--;
|
|
107
|
+
return Promise.resolve(true);
|
|
108
|
+
}
|
|
109
|
+
return new Promise(resolve => {
|
|
110
|
+
const waiter = {
|
|
111
|
+
resolve: (acquired: boolean) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
signal.removeEventListener("abort", onAbort);
|
|
114
|
+
resolve(acquired);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const drop = () => {
|
|
118
|
+
const index = this.#waiters.indexOf(waiter);
|
|
119
|
+
if (index >= 0) this.#waiters.splice(index, 1);
|
|
120
|
+
waiter.resolve(false);
|
|
121
|
+
};
|
|
122
|
+
const onAbort = () => drop();
|
|
123
|
+
const timer = setTimeout(drop, Math.max(0, deadline - Date.now()));
|
|
124
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
125
|
+
this.#waiters.push(waiter);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
release(): void {
|
|
130
|
+
const next = this.#waiters.shift();
|
|
131
|
+
if (next !== undefined) next.resolve(true);
|
|
132
|
+
else this.#available++;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Token bucket used for per-connection control-request rate limits. */
|
|
137
|
+
export class TokenBucket {
|
|
138
|
+
readonly #ratePerSecond: number;
|
|
139
|
+
readonly #capacity: number;
|
|
140
|
+
#tokens: number;
|
|
141
|
+
#updated = Date.now();
|
|
142
|
+
|
|
143
|
+
constructor(ratePerSecond: number, capacity: number) {
|
|
144
|
+
this.#ratePerSecond = ratePerSecond;
|
|
145
|
+
this.#capacity = capacity;
|
|
146
|
+
this.#tokens = capacity;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
take(): boolean {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
this.#tokens = Math.min(this.#capacity, this.#tokens + ((now - this.#updated) / 1000) * this.#ratePerSecond);
|
|
152
|
+
this.#updated = now;
|
|
153
|
+
if (this.#tokens < 1) return false;
|
|
154
|
+
this.#tokens -= 1;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { StreamOtterError, type DiagnosticStep, type Json } from "@streamotter/contracts";
|
|
2
|
+
import type { SourceAdapter, SourceSink } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface FixtureRecord { key: string | null; value: Json }
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Deterministic development source. Records advance in array order only when
|
|
8
|
+
* requested. A paused fixture keeps its position; resume retries that record.
|
|
9
|
+
*/
|
|
10
|
+
export class FixtureSourceAdapter implements SourceAdapter {
|
|
11
|
+
readonly kind = "fixture" as const;
|
|
12
|
+
readonly #records: readonly FixtureRecord[];
|
|
13
|
+
readonly #sink: SourceSink;
|
|
14
|
+
readonly #onCommit: () => void;
|
|
15
|
+
#index = 0;
|
|
16
|
+
#paused = false;
|
|
17
|
+
#stopped = false;
|
|
18
|
+
#chain: Promise<unknown> = Promise.resolve();
|
|
19
|
+
|
|
20
|
+
constructor(records: readonly FixtureRecord[], sink: SourceSink, onCommit: () => void) {
|
|
21
|
+
this.#records = records;
|
|
22
|
+
this.#sink = sink;
|
|
23
|
+
this.#onCommit = onCommit;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get position(): { index: number; total: number; paused: boolean } {
|
|
27
|
+
return { index: this.#index, total: this.#records.length, paused: this.#paused };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async start(): Promise<void> {
|
|
31
|
+
this.#sink.setStatus("healthy");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async stop(): Promise<void> {
|
|
35
|
+
this.#stopped = true;
|
|
36
|
+
await this.#chain.catch(() => undefined);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Processes up to `count` records in order; resolves with the number committed. */
|
|
40
|
+
advance(count: number): Promise<number> {
|
|
41
|
+
const run = this.#chain.then(async () => {
|
|
42
|
+
if (this.#stopped) throw new StreamOtterError("SOURCE_UNAVAILABLE", { message: "The gateway is stopping." });
|
|
43
|
+
if (this.#paused) {
|
|
44
|
+
throw new StreamOtterError("SOURCE_UNAVAILABLE", { message: "The fixture source is paused; resume it after correcting the cause." });
|
|
45
|
+
}
|
|
46
|
+
let advanced = 0;
|
|
47
|
+
while (advanced < count && this.#index < this.#records.length && !this.#stopped) {
|
|
48
|
+
if (!(await this.#step())) break;
|
|
49
|
+
advanced++;
|
|
50
|
+
}
|
|
51
|
+
return advanced;
|
|
52
|
+
});
|
|
53
|
+
this.#chain = run.catch(() => undefined);
|
|
54
|
+
return run;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async resume(): Promise<void> {
|
|
58
|
+
const run = this.#chain.then(async () => {
|
|
59
|
+
if (!this.#paused || this.#stopped) return;
|
|
60
|
+
this.#paused = false;
|
|
61
|
+
this.#sink.setStatus("healthy");
|
|
62
|
+
await this.#step();
|
|
63
|
+
});
|
|
64
|
+
this.#chain = run.catch(() => undefined);
|
|
65
|
+
await run;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async check(): Promise<DiagnosticStep[]> {
|
|
69
|
+
return [
|
|
70
|
+
{ stage: "resolve", outcome: "ok", message: `Fixture with ${this.#records.length} records is registered.` },
|
|
71
|
+
{ stage: "connect", outcome: "skipped", message: "Fixture sources have no network connection." },
|
|
72
|
+
{ stage: "tls", outcome: "skipped", message: "Fixture sources have no network connection." },
|
|
73
|
+
{ stage: "authenticate", outcome: "skipped", message: "Fixture sources have no credentials." },
|
|
74
|
+
{ stage: "metadata", outcome: "ok", message: `Position ${this.#index} of ${this.#records.length}${this.#paused ? " (paused)" : ""}.` }
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Processes the record at the current index. Returns false when it paused or was abandoned. */
|
|
79
|
+
async #step(): Promise<boolean> {
|
|
80
|
+
const record = this.#records[this.#index];
|
|
81
|
+
if (record === undefined) return false;
|
|
82
|
+
const outcome = await this.#sink.process({
|
|
83
|
+
key: record.key,
|
|
84
|
+
value: record.value,
|
|
85
|
+
position: { kind: "fixture", index: String(this.#index) }
|
|
86
|
+
});
|
|
87
|
+
if (outcome.kind === "commit") {
|
|
88
|
+
this.#index++;
|
|
89
|
+
this.#onCommit();
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (outcome.kind === "pause") this.#paused = true;
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { connect as netConnect, isIP, type Socket } from "node:net";
|
|
4
|
+
import { resolve as resolvePath } from "node:path";
|
|
5
|
+
import { connect as tlsConnect, type ConnectionOptions } from "node:tls";
|
|
6
|
+
import kafkajs, { type Consumer, type ISocketFactory, type KafkaConfig, type SASLOptions } from "kafkajs";
|
|
7
|
+
import { StreamOtterError, type DiagnosticStep, type KafkaConnection, type Source, type SourceRecord } from "@streamotter/contracts";
|
|
8
|
+
import { patchKafkaJsRequestQueue } from "./kafkajs-patch.ts";
|
|
9
|
+
import type { SourceAdapter, SourceSink } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const { Kafka, logLevel } = kafkajs;
|
|
12
|
+
patchKafkaJsRequestQueue();
|
|
13
|
+
|
|
14
|
+
export interface ResolvedKafkaConnection {
|
|
15
|
+
readonly brokers: readonly string[];
|
|
16
|
+
readonly tls: boolean;
|
|
17
|
+
readonly ca: string | null;
|
|
18
|
+
readonly sasl: { mechanism: "plain" | "scram-sha-256" | "scram-sha-512"; username: string; password: string } | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Resolves environment secret references and CA files. Never logs resolved values. */
|
|
22
|
+
export async function resolveKafkaConnection(profile: KafkaConnection, configDir: string): Promise<ResolvedKafkaConnection> {
|
|
23
|
+
let sasl: ResolvedKafkaConnection["sasl"] = null;
|
|
24
|
+
if (profile.sasl !== undefined) {
|
|
25
|
+
const missing = [profile.sasl.username.env, profile.sasl.password.env].filter(name => {
|
|
26
|
+
const value = process.env[name];
|
|
27
|
+
return value === undefined || value === "";
|
|
28
|
+
});
|
|
29
|
+
if (missing.length > 0) {
|
|
30
|
+
throw new StreamOtterError("CONFIG_INVALID", {
|
|
31
|
+
message: `Missing environment variable${missing.length > 1 ? "s" : ""} ${missing.join(", ")} referenced by the Kafka connection profile.`
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
sasl = {
|
|
35
|
+
mechanism: profile.sasl.mechanism,
|
|
36
|
+
username: process.env[profile.sasl.username.env] as string,
|
|
37
|
+
password: process.env[profile.sasl.password.env] as string
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
let ca: string | null = null;
|
|
41
|
+
if (profile.tls !== false && profile.tls.caFile !== undefined) {
|
|
42
|
+
const path = resolvePath(configDir, profile.tls.caFile);
|
|
43
|
+
try {
|
|
44
|
+
ca = await readFile(path, "utf8");
|
|
45
|
+
} catch {
|
|
46
|
+
throw new StreamOtterError("CONFIG_INVALID", { message: `The CA file ${profile.tls.caFile} could not be read.` });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { brokers: [...profile.brokers], tls: profile.tls !== false, ca, sasl };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Socket factory equivalent to KafkaJS's default, but tracking every socket so
|
|
54
|
+
* stop() can guarantee release. KafkaJS can leave a broker connection open after
|
|
55
|
+
* reconnecting through a broker restart.
|
|
56
|
+
*/
|
|
57
|
+
export class TrackedSockets {
|
|
58
|
+
readonly #open = new Set<Socket>();
|
|
59
|
+
|
|
60
|
+
readonly factory: ISocketFactory = ({ host, port, ssl, onConnect }) => {
|
|
61
|
+
const socket: Socket = ssl
|
|
62
|
+
? tlsConnect({ host, port, ...(isIP(host) === 0 ? { servername: host } : {}), ...(typeof ssl === "object" ? ssl : {}) } as ConnectionOptions, onConnect)
|
|
63
|
+
: netConnect({ host, port }, onConnect);
|
|
64
|
+
socket.setKeepAlive(true, 60_000);
|
|
65
|
+
this.#open.add(socket);
|
|
66
|
+
socket.once("close", () => this.#open.delete(socket));
|
|
67
|
+
return socket;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
get size(): number {
|
|
71
|
+
return this.#open.size;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Destroys remaining sockets with an error so KafkaJS runs its own error path:
|
|
76
|
+
* it rejects queued requests, disconnects, and stops the connection's timers.
|
|
77
|
+
*/
|
|
78
|
+
destroyAll(): void {
|
|
79
|
+
for (const socket of this.#open) socket.destroy(new Error("closed by StreamOtter during shutdown"));
|
|
80
|
+
this.#open.clear();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function kafkaConfig(
|
|
85
|
+
clientId: string,
|
|
86
|
+
connection: ResolvedKafkaConnection,
|
|
87
|
+
sink: SourceSink | null,
|
|
88
|
+
sockets: TrackedSockets,
|
|
89
|
+
quiet: () => boolean = () => false
|
|
90
|
+
): KafkaConfig {
|
|
91
|
+
const config: KafkaConfig = {
|
|
92
|
+
clientId,
|
|
93
|
+
socketFactory: sockets.factory,
|
|
94
|
+
brokers: [...connection.brokers],
|
|
95
|
+
connectionTimeout: 3_000,
|
|
96
|
+
authenticationTimeout: 10_000,
|
|
97
|
+
requestTimeout: 30_000,
|
|
98
|
+
retry: { initialRetryTime: 300, maxRetryTime: 5_000, retries: 8 },
|
|
99
|
+
logLevel: logLevel.ERROR,
|
|
100
|
+
logCreator: () => entry => {
|
|
101
|
+
// Forward only the namespace and message; KafkaJS extras can include request details.
|
|
102
|
+
if (quiet()) return;
|
|
103
|
+
sink?.logger.warn("Kafka client", { namespace: entry.namespace, message: String(entry.log.message).slice(0, 300) });
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
if (connection.tls) config.ssl = connection.ca === null ? true : { ca: [connection.ca] };
|
|
107
|
+
if (connection.sasl !== null) {
|
|
108
|
+
config.sasl = { mechanism: connection.sasl.mechanism, username: connection.sasl.username, password: connection.sasl.password } as SASLOptions;
|
|
109
|
+
}
|
|
110
|
+
return config;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function nextOffset(offset: string): string {
|
|
114
|
+
return (BigInt(offset) + 1n).toString();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** How long without fetch/heartbeat activity before a healthy source is considered degraded. */
|
|
118
|
+
const WATCHDOG_MS = 12_000;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* KafkaJS adapter with explicit progress management: auto-commit and automatic
|
|
122
|
+
* batch resolution are disabled, records are processed in partition order with
|
|
123
|
+
* heartbeats, and the next offset is committed only after the gateway finishes
|
|
124
|
+
* processing a record. A poison record pauses the whole source and seeks back so
|
|
125
|
+
* resume retries the same record; nothing after it is committed.
|
|
126
|
+
*/
|
|
127
|
+
export class KafkaSourceAdapter implements SourceAdapter {
|
|
128
|
+
readonly kind = "kafka" as const;
|
|
129
|
+
readonly #sourceId: string;
|
|
130
|
+
readonly #source: Extract<Source, { kind: "kafka" }>;
|
|
131
|
+
readonly #connection: ResolvedKafkaConnection;
|
|
132
|
+
readonly #sink: SourceSink;
|
|
133
|
+
readonly #onCommit: () => void;
|
|
134
|
+
readonly #beforeCommit: ((sourceId: string, position: SourceRecord["position"]) => Promise<void>) | undefined;
|
|
135
|
+
readonly #clientId: string;
|
|
136
|
+
#consumer: Consumer | null = null;
|
|
137
|
+
#status: "starting" | "healthy" | "degraded" | "paused" | "stopped" = "starting";
|
|
138
|
+
#paused = false;
|
|
139
|
+
#rebalancing = false;
|
|
140
|
+
#stopping = false;
|
|
141
|
+
#lastActivity = Date.now();
|
|
142
|
+
#watchdog: NodeJS.Timeout | null = null;
|
|
143
|
+
#joined: (() => void) | null = null;
|
|
144
|
+
readonly #sockets = new TrackedSockets();
|
|
145
|
+
|
|
146
|
+
constructor(options: {
|
|
147
|
+
projectId: string;
|
|
148
|
+
sourceId: string;
|
|
149
|
+
source: Extract<Source, { kind: "kafka" }>;
|
|
150
|
+
connection: ResolvedKafkaConnection;
|
|
151
|
+
sink: SourceSink;
|
|
152
|
+
onCommit: () => void;
|
|
153
|
+
beforeCommit?: (sourceId: string, position: SourceRecord["position"]) => Promise<void>;
|
|
154
|
+
}) {
|
|
155
|
+
this.#sourceId = options.sourceId;
|
|
156
|
+
this.#source = options.source;
|
|
157
|
+
this.#connection = options.connection;
|
|
158
|
+
this.#sink = options.sink;
|
|
159
|
+
this.#onCommit = options.onCommit;
|
|
160
|
+
this.#beforeCommit = options.beforeCommit;
|
|
161
|
+
this.#clientId = `streamotter-${options.projectId}-${options.sourceId}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async start(): Promise<void> {
|
|
165
|
+
const kafka = new Kafka(kafkaConfig(this.#clientId, this.#connection, this.#sink, this.#sockets, () => this.#stopping));
|
|
166
|
+
const consumer = kafka.consumer({
|
|
167
|
+
groupId: this.#source.consumerGroup,
|
|
168
|
+
sessionTimeout: 30_000,
|
|
169
|
+
heartbeatInterval: 3_000,
|
|
170
|
+
maxWaitTimeInMs: 1_000,
|
|
171
|
+
allowAutoTopicCreation: false,
|
|
172
|
+
retry: { initialRetryTime: 300, maxRetryTime: 5_000, retries: 8, restartOnFailure: async () => !this.#stopping }
|
|
173
|
+
});
|
|
174
|
+
this.#consumer = consumer;
|
|
175
|
+
this.#instrument(consumer);
|
|
176
|
+
const joined = new Promise<void>(resolve => { this.#joined = resolve; });
|
|
177
|
+
await consumer.connect();
|
|
178
|
+
await consumer.subscribe({ topics: [...this.#source.topics], fromBeginning: this.#source.startFrom === "earliest" });
|
|
179
|
+
await consumer.run({
|
|
180
|
+
autoCommit: false,
|
|
181
|
+
eachBatchAutoResolve: false,
|
|
182
|
+
partitionsConsumedConcurrently: 1,
|
|
183
|
+
eachBatch: async ({ batch, resolveOffset, heartbeat, isRunning, isStale }) => {
|
|
184
|
+
for (const message of batch.messages) {
|
|
185
|
+
if (this.#paused || this.#stopping || !isRunning() || isStale()) return;
|
|
186
|
+
const position = { kind: "kafka" as const, topic: batch.topic, partition: batch.partition, offset: message.offset };
|
|
187
|
+
const outcome = await this.#sink.process({
|
|
188
|
+
key: message.key === null ? null : message.key.toString("utf8"),
|
|
189
|
+
bytes: message.value,
|
|
190
|
+
position
|
|
191
|
+
});
|
|
192
|
+
if (outcome.kind === "abandon") return;
|
|
193
|
+
if (outcome.kind === "pause") {
|
|
194
|
+
this.#pauseAt(batch.topic, batch.partition, message.offset);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
resolveOffset(message.offset);
|
|
198
|
+
if (this.#beforeCommit !== undefined) await this.#beforeCommit(this.#sourceId, position);
|
|
199
|
+
if (this.#stopping) return;
|
|
200
|
+
try {
|
|
201
|
+
await consumer.commitOffsets([{ topic: batch.topic, partition: batch.partition, offset: nextOffset(message.offset) }]);
|
|
202
|
+
this.#onCommit();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Processing completed; a failed commit only means this record can be redelivered.
|
|
205
|
+
this.#sink.logger.warn("Kafka offset commit failed; the record may be redelivered", {
|
|
206
|
+
sourceId: this.#sourceId, topic: batch.topic, partition: batch.partition, offset: message.offset,
|
|
207
|
+
error: (error as Error).name
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
await heartbeat();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
await joined;
|
|
216
|
+
this.#watchdog = setInterval(() => this.#checkWatchdog(), 1_000);
|
|
217
|
+
this.#watchdog.unref();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async stop(deadline: number): Promise<void> {
|
|
221
|
+
this.#stopping = true;
|
|
222
|
+
if (this.#watchdog !== null) clearInterval(this.#watchdog);
|
|
223
|
+
this.#joined?.();
|
|
224
|
+
const consumer = this.#consumer;
|
|
225
|
+
if (consumer !== null) {
|
|
226
|
+
let timer: NodeJS.Timeout | undefined;
|
|
227
|
+
await Promise.race([
|
|
228
|
+
consumer.disconnect().catch(() => undefined),
|
|
229
|
+
new Promise<void>(resolve => { timer = setTimeout(resolve, Math.max(0, deadline - Date.now())); })
|
|
230
|
+
]);
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
}
|
|
233
|
+
if (this.#sockets.size > 0) {
|
|
234
|
+
this.#sink.logger.info("Closing Kafka connections the client left open", { sourceId: this.#sourceId, count: this.#sockets.size });
|
|
235
|
+
this.#sockets.destroyAll();
|
|
236
|
+
}
|
|
237
|
+
this.#status = "stopped";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async resume(): Promise<void> {
|
|
241
|
+
if (!this.#paused || this.#consumer === null) return;
|
|
242
|
+
this.#paused = false;
|
|
243
|
+
this.#setStatus("healthy");
|
|
244
|
+
this.#consumer.resume(this.#source.topics.map(topic => ({ topic })));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async check(deadline: number): Promise<DiagnosticStep[]> {
|
|
248
|
+
return runKafkaDiagnostics(this.#connection, this.#source.topics, deadline);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#pauseAt(topic: string, partition: number, offset: string): void {
|
|
252
|
+
const consumer = this.#consumer;
|
|
253
|
+
this.#paused = true;
|
|
254
|
+
this.#status = "paused";
|
|
255
|
+
if (consumer === null) return;
|
|
256
|
+
consumer.pause(this.#source.topics.map(name => ({ topic: name })));
|
|
257
|
+
consumer.seek({ topic, partition, offset });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#setStatus(status: "healthy" | "degraded", reason?: "SOURCE_UNAVAILABLE"): void {
|
|
261
|
+
if (this.#stopping || this.#paused) return;
|
|
262
|
+
if (this.#status === status) return;
|
|
263
|
+
this.#status = status;
|
|
264
|
+
this.#sink.setStatus(status, reason);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#activity(): void {
|
|
268
|
+
this.#lastActivity = Date.now();
|
|
269
|
+
if (this.#status === "degraded" && !this.#rebalancing) this.#setStatus("healthy");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#checkWatchdog(): void {
|
|
273
|
+
if (this.#status === "healthy" && Date.now() - this.#lastActivity > WATCHDOG_MS) {
|
|
274
|
+
this.#sink.logger.warn("Kafka source has had no broker activity; marking it degraded", { sourceId: this.#sourceId });
|
|
275
|
+
this.#setStatus("degraded", "SOURCE_UNAVAILABLE");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
#instrument(consumer: Consumer): void {
|
|
280
|
+
const { events } = consumer;
|
|
281
|
+
consumer.on(events.GROUP_JOIN, () => {
|
|
282
|
+
this.#rebalancing = false;
|
|
283
|
+
this.#lastActivity = Date.now();
|
|
284
|
+
this.#setStatus("healthy");
|
|
285
|
+
const joined = this.#joined;
|
|
286
|
+
this.#joined = null;
|
|
287
|
+
joined?.();
|
|
288
|
+
});
|
|
289
|
+
consumer.on(events.REBALANCING, () => {
|
|
290
|
+
// Continuity cannot be assumed across a rebalance; subscriptions resynchronize after rejoin.
|
|
291
|
+
this.#rebalancing = true;
|
|
292
|
+
this.#setStatus("degraded", "SOURCE_UNAVAILABLE");
|
|
293
|
+
});
|
|
294
|
+
consumer.on(events.FETCH, () => this.#activity());
|
|
295
|
+
consumer.on(events.HEARTBEAT, () => this.#activity());
|
|
296
|
+
consumer.on(events.COMMIT_OFFSETS, () => this.#activity());
|
|
297
|
+
consumer.on(events.CRASH, event => {
|
|
298
|
+
this.#sink.logger.warn("Kafka consumer crashed", {
|
|
299
|
+
sourceId: this.#sourceId,
|
|
300
|
+
error: event.payload.error.name,
|
|
301
|
+
restart: event.payload.restart
|
|
302
|
+
});
|
|
303
|
+
this.#setStatus("degraded", "SOURCE_UNAVAILABLE");
|
|
304
|
+
});
|
|
305
|
+
consumer.on(events.DISCONNECT, () => {
|
|
306
|
+
if (!this.#stopping) this.#setStatus("degraded", "SOURCE_UNAVAILABLE");
|
|
307
|
+
});
|
|
308
|
+
consumer.on(events.STOP, () => {
|
|
309
|
+
if (!this.#stopping) this.#setStatus("degraded", "SOURCE_UNAVAILABLE");
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function createKafkaSourceAdapter(options: ConstructorParameters<typeof KafkaSourceAdapter>[0]): KafkaSourceAdapter {
|
|
315
|
+
return new KafkaSourceAdapter(options);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function splitBroker(broker: string): { host: string; port: number } {
|
|
319
|
+
const index = broker.lastIndexOf(":");
|
|
320
|
+
const host = broker.slice(0, index).replace(/^\[|\]$/g, "");
|
|
321
|
+
return { host, port: Number(broker.slice(index + 1)) };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function withDeadline<T>(promise: Promise<T>, deadline: number, label: string): Promise<T> {
|
|
325
|
+
let timer: NodeJS.Timeout | undefined;
|
|
326
|
+
return Promise.race([
|
|
327
|
+
promise,
|
|
328
|
+
new Promise<never>((_resolve, reject) => {
|
|
329
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out`)), Math.max(1, deadline - Date.now()));
|
|
330
|
+
})
|
|
331
|
+
]).finally(() => clearTimeout(timer));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Staged diagnostics for an existing profile: resolve → connect → tls →
|
|
336
|
+
* authenticate → metadata. Messages name brokers and stages, never credentials.
|
|
337
|
+
*/
|
|
338
|
+
export async function runKafkaDiagnostics(connection: ResolvedKafkaConnection, topics: readonly string[], deadline: number): Promise<DiagnosticStep[]> {
|
|
339
|
+
const steps: DiagnosticStep[] = [];
|
|
340
|
+
const skipRest = (from: DiagnosticStep["stage"][], message: string) => {
|
|
341
|
+
for (const stage of from) steps.push({ stage, outcome: "skipped", message });
|
|
342
|
+
};
|
|
343
|
+
const brokers = connection.brokers.map(splitBroker);
|
|
344
|
+
const resolved: { host: string; port: number }[] = [];
|
|
345
|
+
for (const broker of brokers) {
|
|
346
|
+
try {
|
|
347
|
+
await withDeadline(lookup(broker.host), deadline, "DNS lookup");
|
|
348
|
+
resolved.push(broker);
|
|
349
|
+
} catch {
|
|
350
|
+
// Reported below.
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (resolved.length === 0) {
|
|
354
|
+
steps.push({ stage: "resolve", outcome: "failed", message: `No broker host could be resolved (${connection.brokers.join(", ")}).` });
|
|
355
|
+
skipRest(["connect", "tls", "authenticate", "metadata"], "Skipped because no broker resolved.");
|
|
356
|
+
return steps;
|
|
357
|
+
}
|
|
358
|
+
steps.push({ stage: "resolve", outcome: "ok", message: `Resolved ${resolved.length} of ${brokers.length} broker host(s).` });
|
|
359
|
+
|
|
360
|
+
let reachable: { host: string; port: number } | null = null;
|
|
361
|
+
for (const broker of resolved) {
|
|
362
|
+
const ok = await withDeadline(new Promise<boolean>(resolve => {
|
|
363
|
+
const socket = netConnect({ host: broker.host, port: broker.port });
|
|
364
|
+
socket.setTimeout(3_000, () => { socket.destroy(); resolve(false); });
|
|
365
|
+
socket.once("connect", () => { socket.destroy(); resolve(true); });
|
|
366
|
+
socket.once("error", () => resolve(false));
|
|
367
|
+
}), deadline, "TCP connect").catch(() => false);
|
|
368
|
+
if (ok) {
|
|
369
|
+
reachable = broker;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (reachable === null) {
|
|
374
|
+
steps.push({ stage: "connect", outcome: "failed", message: "No broker accepted a TCP connection. Check the address, port, and network path." });
|
|
375
|
+
skipRest(["tls", "authenticate", "metadata"], "Skipped because no broker was reachable.");
|
|
376
|
+
return steps;
|
|
377
|
+
}
|
|
378
|
+
steps.push({ stage: "connect", outcome: "ok", message: `Connected to ${reachable.host}:${reachable.port}.` });
|
|
379
|
+
|
|
380
|
+
if (connection.tls) {
|
|
381
|
+
const target = reachable;
|
|
382
|
+
const tlsResult = await withDeadline(new Promise<string | null>(resolve => {
|
|
383
|
+
const options: ConnectionOptions = { host: target.host, port: target.port };
|
|
384
|
+
if (!/^[\d.]+$|:/.test(target.host)) options.servername = target.host;
|
|
385
|
+
if (connection.ca !== null) options.ca = [connection.ca];
|
|
386
|
+
const socket = tlsConnect(options);
|
|
387
|
+
socket.setTimeout(5_000, () => { socket.destroy(); resolve("The TLS handshake timed out."); });
|
|
388
|
+
socket.once("secureConnect", () => { socket.destroy(); resolve(null); });
|
|
389
|
+
socket.once("error", (error: NodeJS.ErrnoException) => resolve(`The TLS handshake failed (${error.code ?? error.name}). Check the CA file and that the listener uses TLS.`));
|
|
390
|
+
}), deadline, "TLS").catch(() => "The TLS handshake timed out.");
|
|
391
|
+
if (tlsResult !== null) {
|
|
392
|
+
steps.push({ stage: "tls", outcome: "failed", message: tlsResult });
|
|
393
|
+
skipRest(["authenticate", "metadata"], "Skipped because TLS failed.");
|
|
394
|
+
return steps;
|
|
395
|
+
}
|
|
396
|
+
steps.push({ stage: "tls", outcome: "ok", message: "TLS handshake succeeded and the certificate is trusted." });
|
|
397
|
+
} else {
|
|
398
|
+
steps.push({ stage: "tls", outcome: "skipped", message: "TLS is disabled for this development profile." });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const sockets = new TrackedSockets();
|
|
402
|
+
const admin = new Kafka({ ...kafkaConfig("streamotter-source-check", connection, null, sockets, () => true), retry: { retries: 0 } }).admin();
|
|
403
|
+
try {
|
|
404
|
+
await withDeadline(admin.connect(), deadline, "Kafka connect");
|
|
405
|
+
steps.push(connection.sasl === null
|
|
406
|
+
? { stage: "authenticate", outcome: "skipped", message: "No SASL mechanism is configured." }
|
|
407
|
+
: { stage: "authenticate", outcome: "ok", message: `SASL ${connection.sasl.mechanism.toUpperCase()} authentication succeeded.` });
|
|
408
|
+
} catch (error) {
|
|
409
|
+
const name = (error as Error).name;
|
|
410
|
+
const authFailure = /SASL|Authentication/i.test(name) || /SASL|Authentication/i.test((error as Error).message);
|
|
411
|
+
steps.push({
|
|
412
|
+
stage: "authenticate",
|
|
413
|
+
outcome: "failed",
|
|
414
|
+
message: authFailure
|
|
415
|
+
? `SASL authentication failed (${name}). Check the credential environment variables and mechanism.`
|
|
416
|
+
: `The Kafka protocol connection failed (${name}).`
|
|
417
|
+
});
|
|
418
|
+
steps.push({ stage: "metadata", outcome: "skipped", message: "Skipped because the connection failed." });
|
|
419
|
+
await admin.disconnect().catch(() => undefined);
|
|
420
|
+
sockets.destroyAll();
|
|
421
|
+
return steps;
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
const existing = new Set(await withDeadline(admin.listTopics(), deadline, "metadata"));
|
|
425
|
+
const missing = topics.filter(topic => !existing.has(topic));
|
|
426
|
+
if (missing.length > 0) {
|
|
427
|
+
steps.push({ stage: "metadata", outcome: "failed", message: `Configured topic(s) not found: ${missing.join(", ")}.` });
|
|
428
|
+
} else {
|
|
429
|
+
const metadata = await withDeadline(admin.fetchTopicMetadata({ topics: [...topics] }), deadline, "metadata");
|
|
430
|
+
const partitions = metadata.topics.reduce((sum, topic) => sum + topic.partitions.length, 0);
|
|
431
|
+
steps.push({ stage: "metadata", outcome: "ok", message: `Found ${topics.length} topic(s) with ${partitions} partition(s).` });
|
|
432
|
+
}
|
|
433
|
+
} catch (error) {
|
|
434
|
+
steps.push({ stage: "metadata", outcome: "failed", message: `Metadata request failed (${(error as Error).name}).` });
|
|
435
|
+
} finally {
|
|
436
|
+
await admin.disconnect().catch(() => undefined);
|
|
437
|
+
sockets.destroyAll();
|
|
438
|
+
}
|
|
439
|
+
return steps;
|
|
440
|
+
}
|