@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,41 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
/** Mirrors KafkaJS's CHECK_PENDING_REQUESTS_INTERVAL. */
|
|
6
|
+
const CHECK_PENDING_REQUESTS_INTERVAL_MS = 10;
|
|
7
|
+
|
|
8
|
+
interface RequestQueueInternals {
|
|
9
|
+
pending: unknown[];
|
|
10
|
+
throttledUntil: number;
|
|
11
|
+
throttleCheckTimeoutId: NodeJS.Timeout | null;
|
|
12
|
+
checkPendingRequests(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let applied = false;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* KafkaJS 2.2.4's RequestQueue.scheduleCheckPendingRequests schedules
|
|
19
|
+
* setTimeout(throttledUntil - Date.now()) even when nothing is pending. When not
|
|
20
|
+
* throttled that delay is hugely negative, Node clamps it to 1 ms (and on Node 24+
|
|
21
|
+
* prints TimeoutNegativeWarning), and the check reschedules itself: every open
|
|
22
|
+
* connection spins a 1 ms timer until it is destroyed. This replacement schedules a
|
|
23
|
+
* check only while requests are queued, never with a negative delay. It applies
|
|
24
|
+
* only to the exact pinned version and stays behind the source adapter boundary.
|
|
25
|
+
*/
|
|
26
|
+
export function patchKafkaJsRequestQueue(): boolean {
|
|
27
|
+
if (applied) return true;
|
|
28
|
+
const version = (require("kafkajs/package.json") as { version: string }).version;
|
|
29
|
+
if (version !== "2.2.4") return false;
|
|
30
|
+
const RequestQueue = require("kafkajs/src/network/requestQueue/index.js") as { prototype: RequestQueueInternals & { scheduleCheckPendingRequests(): void } };
|
|
31
|
+
RequestQueue.prototype.scheduleCheckPendingRequests = function (this: RequestQueueInternals): void {
|
|
32
|
+
if (this.throttleCheckTimeoutId !== null || this.pending.length === 0) return;
|
|
33
|
+
const throttledFor = this.throttledUntil - Date.now();
|
|
34
|
+
this.throttleCheckTimeoutId = setTimeout(() => {
|
|
35
|
+
this.throttleCheckTimeoutId = null;
|
|
36
|
+
this.checkPendingRequests();
|
|
37
|
+
}, throttledFor > 0 ? throttledFor : CHECK_PENDING_REQUESTS_INTERVAL_MS);
|
|
38
|
+
};
|
|
39
|
+
applied = true;
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { DiagnosticStep, ErrorCode, GatewayLogger, Json, SourceRecord, SourceStatus } from "@streamotter/contracts";
|
|
2
|
+
|
|
3
|
+
/** A record as read by an adapter, before gateway decoding and validation. */
|
|
4
|
+
export interface SourceInput {
|
|
5
|
+
key: string | null;
|
|
6
|
+
/** Encoded broker value; null for a tombstone. Absent for fixture records. */
|
|
7
|
+
bytes?: Uint8Array | null;
|
|
8
|
+
/** Pre-decoded fixture value. */
|
|
9
|
+
value?: Json;
|
|
10
|
+
position: SourceRecord["position"];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* commit: processing completed; the adapter may commit past this record.
|
|
15
|
+
* pause: the record is poison or unprocessable; do not commit it or anything after it.
|
|
16
|
+
* abandon: the gateway is stopping; do not commit.
|
|
17
|
+
*/
|
|
18
|
+
export type ProcessOutcome = { kind: "commit" } | { kind: "pause"; code: ErrorCode } | { kind: "abandon" };
|
|
19
|
+
|
|
20
|
+
export interface SourceSink {
|
|
21
|
+
process(input: SourceInput): Promise<ProcessOutcome>;
|
|
22
|
+
/** Adapter-observed readiness changes (connection loss, rebalance, recovery). */
|
|
23
|
+
setStatus(status: SourceStatus["status"], reason?: ErrorCode): void;
|
|
24
|
+
readonly logger: GatewayLogger;
|
|
25
|
+
readonly stopSignal: AbortSignal;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Internal broker boundary. Broker-specific behavior stays behind this interface;
|
|
30
|
+
* it is not a public third-party adapter API.
|
|
31
|
+
*/
|
|
32
|
+
export interface SourceAdapter {
|
|
33
|
+
readonly kind: "kafka" | "fixture";
|
|
34
|
+
/** Opens resources and resolves once the source can deliver records. */
|
|
35
|
+
start(): Promise<void>;
|
|
36
|
+
/** Stops consumption; commits only completed processing. */
|
|
37
|
+
stop(deadline: number): Promise<void>;
|
|
38
|
+
/** Retries a paused source at its uncommitted position. */
|
|
39
|
+
resume(): Promise<void>;
|
|
40
|
+
/** Staged connectivity diagnostics with redacted messages. */
|
|
41
|
+
check(deadline: number): Promise<DiagnosticStep[]>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Server as HttpServer } from "node:http";
|
|
2
|
+
import { Server, type Socket } from "socket.io";
|
|
3
|
+
import {
|
|
4
|
+
EVENTS, HELLO_TIMEOUT_MS,
|
|
5
|
+
type ClientToServerEvents, type DataFrame, type ErrorFrame, type Hello, type Principal, type ServerToClientEvents,
|
|
6
|
+
type StreamError, type SubscriptionFrame
|
|
7
|
+
} from "@streamotter/contracts";
|
|
8
|
+
import type { ConnectionTransport } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export interface HandshakeResult {
|
|
11
|
+
principal: Principal;
|
|
12
|
+
previewSessionId: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SessionHandlers {
|
|
16
|
+
handleSubscribe(payload: unknown, reply: unknown): void;
|
|
17
|
+
handleUnsubscribe(payload: unknown, reply: unknown): void;
|
|
18
|
+
handleResync(payload: unknown, reply: unknown): void;
|
|
19
|
+
handleReceipt(payload: unknown): void;
|
|
20
|
+
handleUnknown(event: string, args: readonly unknown[]): void;
|
|
21
|
+
handleTransportClosed(): void;
|
|
22
|
+
open(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TransportCallbacks {
|
|
26
|
+
/** Validates origin and credentials; resolves to a principal or a public error. */
|
|
27
|
+
authenticate(input: { auth: unknown; origin: string | undefined }): Promise<{ ok: true; value: HandshakeResult } | { ok: false; error: StreamError }>;
|
|
28
|
+
/** Creates the session for an authenticated connection. */
|
|
29
|
+
openSession(result: HandshakeResult, transport: ConnectionTransport): SessionHandlers;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type SocketData = { handshake?: HandshakeResult };
|
|
33
|
+
type StreamSocket = Socket<ClientToServerEvents, ServerToClientEvents, Record<string, never>, SocketData>;
|
|
34
|
+
|
|
35
|
+
const KNOWN_EVENTS: ReadonlySet<string> = new Set([EVENTS.subscribe, EVENTS.unsubscribe, EVENTS.resync, EVENTS.receipt]);
|
|
36
|
+
|
|
37
|
+
class SocketIoConnection implements ConnectionTransport {
|
|
38
|
+
readonly #socket: StreamSocket;
|
|
39
|
+
|
|
40
|
+
constructor(socket: StreamSocket) {
|
|
41
|
+
this.#socket = socket;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
sendHello(hello: Hello): void {
|
|
45
|
+
this.#socket.emit("so:hello", hello);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
sendState(frame: SubscriptionFrame): void {
|
|
49
|
+
this.#socket.emit("so:state", frame);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
sendData(frame: DataFrame): void {
|
|
53
|
+
this.#socket.emit("so:data", frame);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
sendError(frame: ErrorFrame): void {
|
|
57
|
+
this.#socket.emit("so:error", frame);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
close(): void {
|
|
61
|
+
this.#socket.disconnect(true);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Socket.IO adapter: namespace "/", WebSocket-only transport, connection-state
|
|
67
|
+
* recovery disabled (V1 owns resynchronization), control frames bounded by
|
|
68
|
+
* maxControlFrameBytes. Authentication runs in handshake middleware so failures
|
|
69
|
+
* reach the SDK as structured connect errors.
|
|
70
|
+
*/
|
|
71
|
+
export function attachSocketIo(httpServer: HttpServer, options: {
|
|
72
|
+
path: string;
|
|
73
|
+
maxControlFrameBytes: number;
|
|
74
|
+
callbacks: TransportCallbacks;
|
|
75
|
+
}): Server {
|
|
76
|
+
const io = new Server<ClientToServerEvents, ServerToClientEvents, Record<string, never>, SocketData>(httpServer, {
|
|
77
|
+
path: options.path,
|
|
78
|
+
serveClient: false,
|
|
79
|
+
transports: ["websocket"],
|
|
80
|
+
allowUpgrades: false,
|
|
81
|
+
maxHttpBufferSize: options.maxControlFrameBytes,
|
|
82
|
+
connectTimeout: HELLO_TIMEOUT_MS,
|
|
83
|
+
pingInterval: 25_000,
|
|
84
|
+
pingTimeout: 20_000
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
io.use((socket, next) => {
|
|
88
|
+
const origin = socket.handshake.headers.origin;
|
|
89
|
+
options.callbacks.authenticate({ auth: socket.handshake.auth, origin }).then(result => {
|
|
90
|
+
if (!result.ok) {
|
|
91
|
+
const error = new Error(result.error.message) as Error & { data?: StreamError };
|
|
92
|
+
error.data = result.error;
|
|
93
|
+
next(error);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
socket.data.handshake = result.value;
|
|
97
|
+
next();
|
|
98
|
+
}, () => {
|
|
99
|
+
const error = new Error("An unexpected error occurred.") as Error & { data?: StreamError };
|
|
100
|
+
error.data = { code: "INTERNAL", message: "An unexpected error occurred.", retryable: true, requestId: "" };
|
|
101
|
+
next(error);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
io.on("connection", socket => {
|
|
106
|
+
const handshake = socket.data.handshake;
|
|
107
|
+
if (handshake === undefined) {
|
|
108
|
+
socket.disconnect(true);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const session = options.callbacks.openSession(handshake, new SocketIoConnection(socket));
|
|
112
|
+
const untyped = socket as unknown as Socket;
|
|
113
|
+
untyped.on(EVENTS.subscribe, (payload: unknown, reply: unknown) => session.handleSubscribe(payload, reply));
|
|
114
|
+
untyped.on(EVENTS.unsubscribe, (payload: unknown, reply: unknown) => session.handleUnsubscribe(payload, reply));
|
|
115
|
+
untyped.on(EVENTS.resync, (payload: unknown, reply: unknown) => session.handleResync(payload, reply));
|
|
116
|
+
untyped.on(EVENTS.receipt, (payload: unknown) => session.handleReceipt(payload));
|
|
117
|
+
untyped.onAny((event: unknown, ...args: unknown[]) => {
|
|
118
|
+
if (typeof event !== "string" || !KNOWN_EVENTS.has(event)) session.handleUnknown(String(event), args);
|
|
119
|
+
});
|
|
120
|
+
socket.on("disconnect", () => session.handleTransportClosed());
|
|
121
|
+
session.open();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return io;
|
|
125
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DataFrame, ErrorFrame, Hello, SubscriptionFrame } from "@streamotter/contracts";
|
|
2
|
+
|
|
3
|
+
/** Server-side view of one client transport connection. Transport specifics stay in the adapter. */
|
|
4
|
+
export interface ConnectionTransport {
|
|
5
|
+
sendHello(hello: Hello): void;
|
|
6
|
+
sendState(frame: SubscriptionFrame): void;
|
|
7
|
+
sendData(frame: DataFrame): void;
|
|
8
|
+
sendError(frame: ErrorFrame): void;
|
|
9
|
+
close(): void;
|
|
10
|
+
}
|