@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,892 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer, type Server as HttpServer } from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import type { Server as IoServer } from "socket.io";
|
|
5
|
+
import {
|
|
6
|
+
assertValidProjectConfig, canonicalizeParams, canonicalJson, compareRevisions, DEFAULT_STOP_TIMEOUT_MS,
|
|
7
|
+
isJsonValue, isPlainObject, isRevision, MAX_TOKEN_BYTES, parseUtcTimestamp, PREVIEW_TOKEN_TTL_MS,
|
|
8
|
+
resolveLimits, STARTUP_DEADLINE_MS, streamError, StreamOtterError, utf8ByteLength, validateValue,
|
|
9
|
+
type ChannelMap, type ChannelSummary, type DevelopmentOptions, type DevelopmentPrincipalSummary,
|
|
10
|
+
type DiagnosticStep, type ErrorCode, type Gateway, type GatewayLogger, type GatewayOptions, type HandlerRegistry,
|
|
11
|
+
type Json, type Page, type Principal, type ProjectConfig, type Revocation, type Schema, type SourceRecord,
|
|
12
|
+
type SourceStatus, type StreamError, type StreamEvent, type Trace
|
|
13
|
+
} from "@streamotter/contracts";
|
|
14
|
+
import { ByteBudget } from "./budget.ts";
|
|
15
|
+
import { Router, routingKey, type ChannelRuntime, type GatewayCore, type SourceRuntime } from "./core.ts";
|
|
16
|
+
import {
|
|
17
|
+
freezePrincipal, identityKey, matchesPrincipal, principalProblem, RevocationLog, sourceRecordId, updateEventId
|
|
18
|
+
} from "./identity.ts";
|
|
19
|
+
import { ClientSession, type SessionOwner } from "./session.ts";
|
|
20
|
+
import { FRAME_OVERHEAD_BYTES, type PendingFrame, type ServerSubscription } from "./subscription.ts";
|
|
21
|
+
import { TraceBuffer, type TraceQuery } from "./traces.ts";
|
|
22
|
+
import { consoleLogger, describeError, invokeHandler, newId, nowIso, Semaphore, sha256Hex } from "./util.ts";
|
|
23
|
+
import { FixtureSourceAdapter, type FixtureRecord } from "../sources/fixture.ts";
|
|
24
|
+
import { createKafkaSourceAdapter, resolveKafkaConnection, runKafkaDiagnostics, type ResolvedKafkaConnection } from "../sources/kafka.ts";
|
|
25
|
+
import type { ProcessOutcome, SourceAdapter, SourceInput, SourceSink } from "../sources/types.ts";
|
|
26
|
+
import { attachSocketIo, type HandshakeResult } from "../transport/socketio.ts";
|
|
27
|
+
import type { ConnectionTransport } from "../transport/types.ts";
|
|
28
|
+
|
|
29
|
+
type LifecycleState = "idle" | "starting" | "running" | "stopping" | "stopped";
|
|
30
|
+
|
|
31
|
+
class SourceRuntimeImpl implements SourceRuntime {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly config: ProjectConfig["sources"][string];
|
|
34
|
+
readonly channels: ChannelRuntime[] = [];
|
|
35
|
+
adapter: SourceAdapter | null = null;
|
|
36
|
+
status: SourceStatus["status"] = "starting";
|
|
37
|
+
reason: ErrorCode | undefined = undefined;
|
|
38
|
+
|
|
39
|
+
constructor(id: string, config: ProjectConfig["sources"][string]) {
|
|
40
|
+
this.id = id;
|
|
41
|
+
this.config = config;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get ready(): boolean {
|
|
45
|
+
return this.status === "healthy";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
summary(): SourceStatus {
|
|
49
|
+
const status: SourceStatus = { sourceId: this.id, kind: this.config.kind, status: this.status };
|
|
50
|
+
if (this.reason !== undefined && this.status !== "healthy") status.reason = this.reason;
|
|
51
|
+
return status;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface RoutedOutput { channel: ChannelRuntime; key: string; frame: PendingFrame }
|
|
56
|
+
|
|
57
|
+
interface PreviewSession { principal: Principal; expiresAtMs: number }
|
|
58
|
+
|
|
59
|
+
/** Test-only instrumentation; not reachable through the public createGateway(). */
|
|
60
|
+
export interface InternalGatewayOptions {
|
|
61
|
+
/** Awaited after a Kafka record is processed and before its offset is committed. */
|
|
62
|
+
beforeCommit?: (sourceId: string, position: SourceRecord["position"]) => Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Development and management access to a running gateway; never exposed to browsers. */
|
|
66
|
+
export interface GatewayInternals {
|
|
67
|
+
readonly mode: "development" | "production";
|
|
68
|
+
readonly config: ProjectConfig;
|
|
69
|
+
readonly fingerprint: string;
|
|
70
|
+
readonly limits: GatewayCore["limits"];
|
|
71
|
+
readonly logger: GatewayLogger;
|
|
72
|
+
readonly running: boolean;
|
|
73
|
+
address(): { origin: string; path: string } | null;
|
|
74
|
+
health(): { ready: boolean; sources: SourceStatus[] };
|
|
75
|
+
sources(): SourceStatus[];
|
|
76
|
+
channels(): ChannelSummary[];
|
|
77
|
+
traces(query: TraceQuery): Page<Trace>;
|
|
78
|
+
checkSource(sourceId: string): Promise<DiagnosticStep[]>;
|
|
79
|
+
checkAllSources(): Promise<{ sourceId: string; steps: DiagnosticStep[] }[]>;
|
|
80
|
+
resumeSource(sourceId: string): Promise<SourceStatus>;
|
|
81
|
+
allowDevelopmentOrigin(origin: string): void;
|
|
82
|
+
developmentPrincipals(): DevelopmentPrincipalSummary[];
|
|
83
|
+
createPreviewSession(principalRef: string): { token: string; expiresAt: string; previewSessionId: string };
|
|
84
|
+
disconnectPreviewSession(previewSessionId: string): void;
|
|
85
|
+
advanceFixture(sourceId: string, count: number): Promise<number>;
|
|
86
|
+
onStop(callback: () => Promise<void> | void): void;
|
|
87
|
+
connectionCount(): number;
|
|
88
|
+
subscriptionCount(): number;
|
|
89
|
+
pendingBytes(): number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const internalsRegistry = new WeakMap<Gateway, GatewayInternals>();
|
|
93
|
+
|
|
94
|
+
export function getGatewayInternals(gateway: Gateway): GatewayInternals {
|
|
95
|
+
const internals = internalsRegistry.get(gateway);
|
|
96
|
+
if (internals === undefined) throw new StreamOtterError("INVALID_REQUEST", { message: "Not a StreamOtter gateway instance." });
|
|
97
|
+
return internals;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function notFound(message: string): StreamOtterError {
|
|
101
|
+
return new StreamOtterError("INVALID_REQUEST", { message, details: { status: 404 } });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function conflict(message: string): StreamOtterError {
|
|
105
|
+
return new StreamOtterError("SOURCE_UNAVAILABLE", { message, details: { status: 409 } });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateHandlers(config: ProjectConfig, handlers: unknown): asserts handlers is HandlerRegistry<ChannelMap> {
|
|
109
|
+
const issues: string[] = [];
|
|
110
|
+
const registry = handlers as Partial<HandlerRegistry<ChannelMap>> | null;
|
|
111
|
+
if (typeof registry !== "object" || registry === null) {
|
|
112
|
+
issues.push("handlers must be an object");
|
|
113
|
+
} else {
|
|
114
|
+
if (typeof registry.authenticate !== "function") issues.push("handlers.authenticate must be a function");
|
|
115
|
+
const channels = registry.channels as Record<string, unknown> | undefined;
|
|
116
|
+
if (typeof channels !== "object" || channels === null) {
|
|
117
|
+
issues.push("handlers.channels must be an object");
|
|
118
|
+
} else {
|
|
119
|
+
for (const [name, channel] of Object.entries(config.channels)) {
|
|
120
|
+
const entry = channels[channel.handlersRef] as Record<string, unknown> | undefined;
|
|
121
|
+
if (typeof entry !== "object" || entry === null) {
|
|
122
|
+
issues.push(`handlers.channels.${name} is missing`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
for (const fn of ["authorize", "map", "snapshot"]) {
|
|
126
|
+
if (typeof entry[fn] !== "function") issues.push(`handlers.channels.${name}.${fn} must be a function`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const name of Object.keys(channels)) {
|
|
130
|
+
if (!Object.hasOwn(config.channels, name)) issues.push(`handlers.channels.${name} does not match a configured channel`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (issues.length > 0) {
|
|
135
|
+
throw new StreamOtterError("CONFIG_INVALID", { message: `Invalid handler registry: ${issues.join("; ")}.`, details: { issues } });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validateDevelopment(config: ProjectConfig, development: DevelopmentOptions | undefined): void {
|
|
140
|
+
const issues: string[] = [];
|
|
141
|
+
for (const [id, source] of Object.entries(config.sources)) {
|
|
142
|
+
if (source.kind !== "fixture") continue;
|
|
143
|
+
const records = development?.fixtures[source.fixtureRef];
|
|
144
|
+
if (!Array.isArray(records)) {
|
|
145
|
+
issues.push(`development.fixtures.${source.fixtureRef} is required by source ${id}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
records.forEach((record: unknown, index) => {
|
|
149
|
+
if (!isPlainObject(record) || !(record["key"] === null || typeof record["key"] === "string") || !isJsonValue(record["value"])) {
|
|
150
|
+
issues.push(`development.fixtures.${source.fixtureRef}[${index}] must be {key: string | null, value: JSON}`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
for (const [ref, principal] of Object.entries(development?.principals ?? {})) {
|
|
155
|
+
const problem = principalProblem(principal);
|
|
156
|
+
if (problem !== null) issues.push(`development.principals.${ref}: ${problem}`);
|
|
157
|
+
}
|
|
158
|
+
if (issues.length > 0) {
|
|
159
|
+
throw new StreamOtterError("CONFIG_INVALID", { message: `Invalid development options: ${issues.join("; ")}.`, details: { issues } });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validateProduction(config: ProjectConfig, options: GatewayOptions<ChannelMap>): void {
|
|
164
|
+
const issues: string[] = [];
|
|
165
|
+
if (options.development !== undefined) issues.push("development options are rejected in production mode");
|
|
166
|
+
for (const [id, source] of Object.entries(config.sources)) {
|
|
167
|
+
if (source.kind === "fixture") issues.push(`source ${id} is a fixture; fixture sources are development-only`);
|
|
168
|
+
if (source.kind === "kafka" && config.connections[source.connectionRef]?.tls === false) {
|
|
169
|
+
issues.push(`source ${id} uses plaintext Kafka; plaintext connections are development-only`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (issues.length > 0) {
|
|
173
|
+
throw new StreamOtterError("CONFIG_INVALID", { message: `Invalid production configuration: ${issues.join("; ")}.`, details: { issues } });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The V1 single-process gateway. Construct with createGateway(). */
|
|
178
|
+
export class GatewayRuntime implements SessionOwner {
|
|
179
|
+
readonly core: GatewayCore;
|
|
180
|
+
readonly config: ProjectConfig;
|
|
181
|
+
readonly fingerprint: string;
|
|
182
|
+
readonly mode: "development" | "production";
|
|
183
|
+
readonly #handlers: HandlerRegistry<ChannelMap>;
|
|
184
|
+
readonly #development: DevelopmentOptions | undefined;
|
|
185
|
+
readonly #configDir: string;
|
|
186
|
+
readonly #sources = new Map<string, SourceRuntimeImpl>();
|
|
187
|
+
readonly #channels = new Map<string, ChannelRuntime>();
|
|
188
|
+
readonly #sessions = new Set<ClientSession>();
|
|
189
|
+
readonly #allowedOrigins: Set<string>;
|
|
190
|
+
readonly #previews = new Map<string, PreviewSession>();
|
|
191
|
+
readonly #previewTokens = new Map<string, string>();
|
|
192
|
+
readonly #stopController = new AbortController();
|
|
193
|
+
readonly #stopCallbacks: (() => Promise<void> | void)[] = [];
|
|
194
|
+
#state: LifecycleState = "idle";
|
|
195
|
+
#starting: Promise<{ origin: string; path: string }> | null = null;
|
|
196
|
+
#stopping: Promise<void> | null = null;
|
|
197
|
+
#address: { origin: string; path: string } | null = null;
|
|
198
|
+
#http: HttpServer | null = null;
|
|
199
|
+
#io: IoServer | null = null;
|
|
200
|
+
#pendingHandshakes = 0;
|
|
201
|
+
#activeChecks = 0;
|
|
202
|
+
readonly #internal: InternalGatewayOptions;
|
|
203
|
+
|
|
204
|
+
constructor(options: GatewayOptions<ChannelMap>, internal: InternalGatewayOptions = {}) {
|
|
205
|
+
this.#internal = internal;
|
|
206
|
+
if (options.mode !== "development" && options.mode !== "production") {
|
|
207
|
+
throw new StreamOtterError("CONFIG_INVALID", { message: "mode must be development or production." });
|
|
208
|
+
}
|
|
209
|
+
assertValidProjectConfig(options.config);
|
|
210
|
+
const config = options.config;
|
|
211
|
+
validateHandlers(config, options.handlers);
|
|
212
|
+
if (options.mode === "production") validateProduction(config, options);
|
|
213
|
+
else validateDevelopment(config, options.development);
|
|
214
|
+
|
|
215
|
+
this.config = config;
|
|
216
|
+
this.mode = options.mode;
|
|
217
|
+
this.fingerprint = sha256Hex(config);
|
|
218
|
+
this.#handlers = options.handlers;
|
|
219
|
+
this.#development = options.development;
|
|
220
|
+
this.#configDir = options.configDir ?? process.cwd();
|
|
221
|
+
this.#allowedOrigins = new Set(config.gateway.allowedOrigins);
|
|
222
|
+
const limits = resolveLimits(config.limits);
|
|
223
|
+
this.core = {
|
|
224
|
+
projectId: config.projectId,
|
|
225
|
+
mode: options.mode,
|
|
226
|
+
limits,
|
|
227
|
+
traces: new TraceBuffer(limits.maxTraceEntries, limits.maxTraceBytes),
|
|
228
|
+
router: new Router(),
|
|
229
|
+
snapshots: new Semaphore(limits.maxConcurrentSnapshots),
|
|
230
|
+
revocations: new RevocationLog(Math.max(60_000, limits.snapshotTimeoutMs + limits.handlerTimeoutMs * 3)),
|
|
231
|
+
logger: options.logger ?? consoleLogger(),
|
|
232
|
+
gatewayBudget: new ByteBudget(limits.maxPendingBytesGateway)
|
|
233
|
+
};
|
|
234
|
+
for (const [id, source] of Object.entries(config.sources)) this.#sources.set(id, new SourceRuntimeImpl(id, source));
|
|
235
|
+
for (const [name, channel] of Object.entries(config.channels)) {
|
|
236
|
+
const source = this.#sources.get(channel.source);
|
|
237
|
+
const handlers = this.#handlers.channels[channel.handlersRef];
|
|
238
|
+
if (source === undefined || handlers === undefined) continue; // Unreachable after validation.
|
|
239
|
+
const runtime: ChannelRuntime = {
|
|
240
|
+
name,
|
|
241
|
+
version: channel.version,
|
|
242
|
+
handlers,
|
|
243
|
+
paramsSchema: config.schemas[channel.paramsSchema] as Schema,
|
|
244
|
+
payloadSchema: config.schemas[channel.payloadSchema] as Schema,
|
|
245
|
+
source,
|
|
246
|
+
subscriptions: new Set()
|
|
247
|
+
};
|
|
248
|
+
this.#channels.set(name, runtime);
|
|
249
|
+
source.channels.push(runtime);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- public lifecycle ------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
start(): Promise<{ origin: string; path: string }> {
|
|
256
|
+
if (this.#state === "stopping" || this.#state === "stopped") {
|
|
257
|
+
return Promise.reject(new StreamOtterError("INVALID_REQUEST", { message: "A stopped gateway cannot restart; construct a new instance." }));
|
|
258
|
+
}
|
|
259
|
+
if (this.#state === "running" && this.#address !== null) return Promise.resolve(this.#address);
|
|
260
|
+
if (this.#starting !== null) return this.#starting;
|
|
261
|
+
this.#state = "starting";
|
|
262
|
+
const starting = this.#doStart();
|
|
263
|
+
this.#starting = starting;
|
|
264
|
+
starting.then(() => { this.#starting = null; }, () => { this.#starting = null; });
|
|
265
|
+
return starting;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
stop(options?: { timeoutMs?: number }): Promise<void> {
|
|
269
|
+
if (this.#stopping !== null) return this.#stopping;
|
|
270
|
+
this.#stopping = this.#doStop(options?.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS);
|
|
271
|
+
return this.#stopping;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async revoke(request: Revocation): Promise<{ closedSubscriptions: number; closedConnections: number }> {
|
|
275
|
+
const selector = this.#validateRevocation(request);
|
|
276
|
+
let canonicalParams: string | null = null;
|
|
277
|
+
if (selector.kind === "channel" && selector.params !== undefined) {
|
|
278
|
+
const channel = this.#channels.get(selector.channel);
|
|
279
|
+
const canonical = channel === undefined ? null : canonicalizeParams(channel.paramsSchema, selector.params);
|
|
280
|
+
canonicalParams = canonical !== null && canonical.ok ? canonical.canonical : canonicalJson(selector.params);
|
|
281
|
+
}
|
|
282
|
+
this.core.revocations.add(selector, canonicalParams);
|
|
283
|
+
let closedSubscriptions = 0;
|
|
284
|
+
let closedConnections = 0;
|
|
285
|
+
const requestId = newId();
|
|
286
|
+
for (const session of [...this.#sessions]) {
|
|
287
|
+
if (!matchesPrincipal(selector, session.principal)) continue;
|
|
288
|
+
if (selector.kind === "channel") {
|
|
289
|
+
for (const subscription of [...session.subscriptions()]) {
|
|
290
|
+
if (subscription.channel.name !== selector.channel || subscription.channel.version !== selector.channelVersion) continue;
|
|
291
|
+
if (canonicalParams !== null && subscription.canonicalParams !== canonicalParams) continue;
|
|
292
|
+
subscription.fail("FORBIDDEN", requestId, "Access to this subscription was revoked.");
|
|
293
|
+
closedSubscriptions++;
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
closedSubscriptions += session.subscriptionCount;
|
|
297
|
+
closedConnections++;
|
|
298
|
+
session.close(streamError("UNAUTHENTICATED", { message: "Access was revoked; authenticate again.", retryable: false, requestId }));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
this.core.logger.info("Access revoked", { kind: selector.kind, closedSubscriptions, closedConnections });
|
|
302
|
+
return { closedSubscriptions, closedConnections };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async resumeSource(sourceId: string): Promise<SourceStatus> {
|
|
306
|
+
const source = this.#sources.get(sourceId);
|
|
307
|
+
if (source === undefined) throw notFound(`Unknown source "${sourceId}".`);
|
|
308
|
+
if (this.#state !== "running" || source.adapter === null) throw conflict("The gateway is not running.");
|
|
309
|
+
if (source.status === "healthy") return source.summary();
|
|
310
|
+
if (source.status !== "paused") throw conflict(`Source "${sourceId}" is ${source.status}; only paused sources can be resumed.`);
|
|
311
|
+
this.core.logger.info("Resuming source at its uncommitted position", { sourceId });
|
|
312
|
+
await source.adapter.resume();
|
|
313
|
+
return source.summary();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// --- SessionOwner ----------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
channel(name: string): ChannelRuntime | undefined {
|
|
319
|
+
return this.#channels.get(name);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
sessionClosed(session: ClientSession): void {
|
|
323
|
+
this.#sessions.delete(session);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// --- transport callbacks ---------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
async authenticateHandshake(input: { auth: unknown; origin: string | undefined }): Promise<{ ok: true; value: HandshakeResult } | { ok: false; error: StreamError }> {
|
|
329
|
+
const requestId = newId();
|
|
330
|
+
const reject = (code: ErrorCode, message?: string, retryable?: boolean) => {
|
|
331
|
+
this.core.traces.record({ requestId, stage: "authorize", outcome: "rejected", errorCode: code });
|
|
332
|
+
const options: { requestId: string; message?: string; retryable?: boolean } = { requestId };
|
|
333
|
+
if (message !== undefined) options.message = message;
|
|
334
|
+
if (retryable !== undefined) options.retryable = retryable;
|
|
335
|
+
return { ok: false as const, error: streamError(code, options) };
|
|
336
|
+
};
|
|
337
|
+
if (this.#state !== "running") return reject("OVERLOADED", "The gateway is not accepting connections.");
|
|
338
|
+
const { origin, auth } = input;
|
|
339
|
+
if (origin === undefined || origin === "") {
|
|
340
|
+
if (this.mode === "production") return reject("FORBIDDEN", "An Origin header is required.");
|
|
341
|
+
} else if (!this.#allowedOrigins.has(origin)) {
|
|
342
|
+
return reject("FORBIDDEN", "This origin is not allowed.");
|
|
343
|
+
}
|
|
344
|
+
if (!isPlainObject(auth)) return reject("UNAUTHENTICATED", undefined, false);
|
|
345
|
+
if (Object.keys(auth).some(key => key !== "token" && key !== "protocolVersion")) {
|
|
346
|
+
return reject("INVALID_REQUEST", "Only token and protocolVersion are accepted in the authentication payload.");
|
|
347
|
+
}
|
|
348
|
+
if (auth["protocolVersion"] !== 1) return reject("UNSUPPORTED_CAPABILITY", "This gateway requires protocol version 1.");
|
|
349
|
+
const token = auth["token"];
|
|
350
|
+
if (typeof token !== "string" || token.length === 0 || utf8ByteLength(token) > MAX_TOKEN_BYTES) {
|
|
351
|
+
return reject("UNAUTHENTICATED", "A non-empty token of at most 8 KiB is required.", false);
|
|
352
|
+
}
|
|
353
|
+
if (this.#sessions.size + this.#pendingHandshakes >= this.core.limits.maxConnections) {
|
|
354
|
+
return reject("OVERLOADED", "The gateway has reached its connection limit.");
|
|
355
|
+
}
|
|
356
|
+
this.#pendingHandshakes++;
|
|
357
|
+
try {
|
|
358
|
+
const revocationSeq = this.core.revocations.sequence;
|
|
359
|
+
let principal: Principal;
|
|
360
|
+
let previewSessionId: string | null = null;
|
|
361
|
+
const previewId = this.mode === "development" ? this.#previewTokens.get(token) : undefined;
|
|
362
|
+
if (previewId !== undefined) {
|
|
363
|
+
const preview = this.#previews.get(previewId);
|
|
364
|
+
if (preview === undefined || preview.expiresAtMs <= Date.now()) return reject("UNAUTHENTICATED", "The preview token has expired.", false);
|
|
365
|
+
principal = preview.principal;
|
|
366
|
+
previewSessionId = previewId;
|
|
367
|
+
} else {
|
|
368
|
+
const outcome = await invokeHandler(
|
|
369
|
+
context => this.#handlers.authenticate({ ...context, token, origin: origin ?? "" }),
|
|
370
|
+
{ timeoutMs: this.core.limits.handlerTimeoutMs, requestId, parent: this.#stopController.signal }
|
|
371
|
+
);
|
|
372
|
+
if (outcome.kind === "aborted") return reject("OVERLOADED", "The gateway is shutting down.");
|
|
373
|
+
if (outcome.kind === "timeout") {
|
|
374
|
+
this.core.logger.warn("authenticate handler timed out", { requestId });
|
|
375
|
+
return reject("HANDLER_FAILED", "Authentication could not be completed; try again.");
|
|
376
|
+
}
|
|
377
|
+
if (outcome.kind === "error") {
|
|
378
|
+
this.core.logger.warn("authenticate handler failed", { requestId, error: describeError(outcome.error) });
|
|
379
|
+
return reject("HANDLER_FAILED", "Authentication could not be completed; try again.");
|
|
380
|
+
}
|
|
381
|
+
if (outcome.value === null) return reject("UNAUTHENTICATED", undefined, false);
|
|
382
|
+
const problem = principalProblem(outcome.value);
|
|
383
|
+
if (problem !== null) {
|
|
384
|
+
this.core.logger.warn("authenticate returned an invalid principal", { requestId, reason: problem });
|
|
385
|
+
return reject(problem.includes("future") ? "UNAUTHENTICATED" : "HANDLER_FAILED",
|
|
386
|
+
problem.includes("future") ? "The token has expired." : "Authentication could not be completed; try again.",
|
|
387
|
+
problem.includes("future") ? false : true);
|
|
388
|
+
}
|
|
389
|
+
principal = freezePrincipal(outcome.value);
|
|
390
|
+
}
|
|
391
|
+
if (this.core.revocations.revokedSince(revocationSeq, principal)) return reject("UNAUTHENTICATED", "Access was revoked.", false);
|
|
392
|
+
if (this.#state !== "running") return reject("OVERLOADED", "The gateway is not accepting connections.");
|
|
393
|
+
this.core.traces.record({ requestId, stage: "authorize", outcome: "ok" });
|
|
394
|
+
return { ok: true, value: { principal, previewSessionId } };
|
|
395
|
+
} finally {
|
|
396
|
+
this.#pendingHandshakes--;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
openSession(result: HandshakeResult, transport: ConnectionTransport): ClientSession {
|
|
401
|
+
const session = new ClientSession({
|
|
402
|
+
owner: this,
|
|
403
|
+
transport,
|
|
404
|
+
principal: result.principal,
|
|
405
|
+
identityKey: identityKey(this.config.projectId, result.principal),
|
|
406
|
+
previewSessionId: result.previewSessionId
|
|
407
|
+
});
|
|
408
|
+
this.#sessions.add(session);
|
|
409
|
+
if (this.#state !== "running") queueMicrotask(() => session.close());
|
|
410
|
+
return session;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --- record pipeline -------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
#sink(source: SourceRuntimeImpl): SourceSink {
|
|
416
|
+
return {
|
|
417
|
+
process: input => this.#process(source, input),
|
|
418
|
+
setStatus: (status, reason) => this.#setSourceStatus(source, status, reason),
|
|
419
|
+
logger: this.core.logger,
|
|
420
|
+
stopSignal: this.#stopController.signal
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#setSourceStatus(source: SourceRuntimeImpl, status: SourceStatus["status"], reason?: ErrorCode): void {
|
|
425
|
+
const wasReady = source.ready;
|
|
426
|
+
if (source.status === status && source.reason === reason) return;
|
|
427
|
+
source.status = status;
|
|
428
|
+
source.reason = status === "healthy" ? undefined : reason;
|
|
429
|
+
if (status !== "healthy") this.core.logger.warn("Source not ready", { sourceId: source.id, status, ...(reason === undefined ? {} : { reason }) });
|
|
430
|
+
else if (!wasReady) this.core.logger.info("Source ready", { sourceId: source.id });
|
|
431
|
+
if (wasReady && !source.ready) {
|
|
432
|
+
for (const channel of source.channels) {
|
|
433
|
+
for (const subscription of [...channel.subscriptions]) subscription.onSourceUnavailable("SOURCE_UNAVAILABLE");
|
|
434
|
+
}
|
|
435
|
+
} else if (!wasReady && source.ready) {
|
|
436
|
+
for (const channel of source.channels) {
|
|
437
|
+
for (const subscription of [...channel.subscriptions]) subscription.onSourceReady();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
#halting(): boolean {
|
|
443
|
+
return this.#state === "stopping" || this.#state === "stopped";
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async #process(source: SourceRuntimeImpl, input: SourceInput): Promise<ProcessOutcome> {
|
|
447
|
+
if (this.#halting()) return { kind: "abandon" };
|
|
448
|
+
const requestId = newId();
|
|
449
|
+
const { limits, traces } = this.core;
|
|
450
|
+
const trace = (stage: Trace["stage"], outcome: Trace["outcome"], extra: Partial<Pick<Trace, "channel" | "subscriptionId" | "errorCode">> = {}) =>
|
|
451
|
+
traces.record({ requestId, stage, outcome, sourceId: source.id, ...extra });
|
|
452
|
+
const pause = (stage: Trace["stage"], code: ErrorCode, reason: string, channel?: string): ProcessOutcome => {
|
|
453
|
+
trace(stage, stage === "map" ? "failed" : "rejected", channel === undefined ? { errorCode: code } : { errorCode: code, channel });
|
|
454
|
+
this.core.logger.warn("Source paused on an unprocessable record; it will not be committed or skipped", {
|
|
455
|
+
sourceId: source.id,
|
|
456
|
+
position: input.position as unknown as Json,
|
|
457
|
+
code,
|
|
458
|
+
reason,
|
|
459
|
+
...(channel === undefined ? {} : { channel })
|
|
460
|
+
});
|
|
461
|
+
this.#setSourceStatus(source, "paused", code);
|
|
462
|
+
return { kind: "pause", code };
|
|
463
|
+
};
|
|
464
|
+
trace("source", "ok");
|
|
465
|
+
|
|
466
|
+
let value: Json;
|
|
467
|
+
if (input.value !== undefined) {
|
|
468
|
+
if (!isJsonValue(input.value)) return pause("validate", "INVALID_PAYLOAD", "fixture value is not JSON data");
|
|
469
|
+
if (utf8ByteLength(JSON.stringify(input.value)) > limits.maxSourceRecordBytes) {
|
|
470
|
+
return pause("validate", "INVALID_PAYLOAD", "record exceeds maxSourceRecordBytes");
|
|
471
|
+
}
|
|
472
|
+
value = input.value;
|
|
473
|
+
} else {
|
|
474
|
+
const bytes = input.bytes ?? null;
|
|
475
|
+
if (bytes === null) return pause("validate", "INVALID_PAYLOAD", "tombstone records have no V1 meaning; represent deletion as explicit state");
|
|
476
|
+
if (bytes.byteLength > limits.maxSourceRecordBytes) return pause("validate", "INVALID_PAYLOAD", "record exceeds maxSourceRecordBytes");
|
|
477
|
+
try {
|
|
478
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as Json;
|
|
479
|
+
} catch {
|
|
480
|
+
return pause("validate", "INVALID_PAYLOAD", "record value is not valid UTF-8 JSON");
|
|
481
|
+
}
|
|
482
|
+
if (!isJsonValue(value)) return pause("validate", "INVALID_PAYLOAD", "record value exceeds the nesting limit");
|
|
483
|
+
}
|
|
484
|
+
trace("validate", "ok");
|
|
485
|
+
|
|
486
|
+
const record: SourceRecord = Object.freeze({
|
|
487
|
+
id: sourceRecordId(this.config.projectId, source.id, source.config.generation, input.position),
|
|
488
|
+
sourceId: source.id,
|
|
489
|
+
key: input.key,
|
|
490
|
+
value,
|
|
491
|
+
receivedAt: nowIso(),
|
|
492
|
+
position: Object.freeze({ ...input.position })
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Map for every channel and validate every output before admitting any of them.
|
|
496
|
+
const outputs: RoutedOutput[] = [];
|
|
497
|
+
for (const channel of source.channels) {
|
|
498
|
+
const outcome = await invokeHandler(
|
|
499
|
+
context => channel.handlers.map({ ...context, record }),
|
|
500
|
+
{ timeoutMs: limits.handlerTimeoutMs, requestId, parent: this.#stopController.signal }
|
|
501
|
+
);
|
|
502
|
+
if (outcome.kind === "aborted" || this.#halting()) return { kind: "abandon" };
|
|
503
|
+
if (outcome.kind === "timeout") return pause("map", "TIMEOUT", "map handler timed out", channel.name);
|
|
504
|
+
if (outcome.kind === "error") {
|
|
505
|
+
return pause("map", "HANDLER_FAILED", `map handler threw ${JSON.stringify(describeError(outcome.error))}`, channel.name);
|
|
506
|
+
}
|
|
507
|
+
const mapped: unknown = outcome.value;
|
|
508
|
+
if (!Array.isArray(mapped)) return pause("map", "INVALID_PAYLOAD", "map must return an array", channel.name);
|
|
509
|
+
if (mapped.length > limits.maxMapOutputs) return pause("map", "INVALID_PAYLOAD", `map returned more than ${limits.maxMapOutputs} outputs`, channel.name);
|
|
510
|
+
for (let index = 0; index < mapped.length; index++) {
|
|
511
|
+
const built = this.#buildOutput(channel, record, mapped[index]);
|
|
512
|
+
if (typeof built === "string") return pause("map", "INVALID_PAYLOAD", `output ${index}: ${built}`, channel.name);
|
|
513
|
+
outputs.push(built);
|
|
514
|
+
}
|
|
515
|
+
trace("map", mapped.length === 0 ? "filtered" : "ok", { channel: channel.name });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Equal revisions with different canonical data conflict with current state.
|
|
519
|
+
const seen = new Map<string, { revision: string; dataHash: string }>();
|
|
520
|
+
for (const output of outputs) {
|
|
521
|
+
const { revision, dataHash } = output.frame;
|
|
522
|
+
const earlier = seen.get(output.key);
|
|
523
|
+
let conflicting = earlier !== undefined && earlier.revision === revision && earlier.dataHash !== dataHash;
|
|
524
|
+
for (const subscription of this.core.router.get(output.key) ?? []) {
|
|
525
|
+
if (conflicting) break;
|
|
526
|
+
conflicting = (subscription as ServerSubscription).conflicts(revision, dataHash);
|
|
527
|
+
}
|
|
528
|
+
if (conflicting) return pause("queue", "REVISION_CONFLICT", "the same revision was mapped to different data", output.channel.name);
|
|
529
|
+
if (earlier === undefined || compareRevisions(revision, earlier.revision) > 0) seen.set(output.key, { revision, dataHash });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
for (const output of outputs) {
|
|
533
|
+
const subscriptions = this.core.router.get(output.key);
|
|
534
|
+
if (subscriptions === undefined) continue;
|
|
535
|
+
for (const subscription of [...subscriptions] as ServerSubscription[]) subscription.admit(output.frame, requestId);
|
|
536
|
+
}
|
|
537
|
+
return { kind: "commit" };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
#buildOutput(channel: ChannelRuntime, record: SourceRecord, item: unknown): RoutedOutput | string {
|
|
541
|
+
if (!isPlainObject(item)) return "must be an object";
|
|
542
|
+
for (const key of Object.keys(item)) {
|
|
543
|
+
if (key !== "tenantId" && key !== "params" && key !== "revision" && key !== "data") return `unexpected field "${key}"`;
|
|
544
|
+
}
|
|
545
|
+
const { tenantId, params, revision, data } = item;
|
|
546
|
+
if (typeof tenantId !== "string" || tenantId.length === 0 || tenantId.length > 512) return "tenantId must be a non-empty string";
|
|
547
|
+
const canonical = canonicalizeParams(channel.paramsSchema, params);
|
|
548
|
+
if (!canonical.ok) return `params ${canonical.issue.path}: ${canonical.issue.message}`;
|
|
549
|
+
if (!isRevision(revision)) return "revision must be a canonical unsigned decimal string";
|
|
550
|
+
if (!isJsonValue(data)) return "data must be JSON";
|
|
551
|
+
const issue = validateValue(channel.payloadSchema, data);
|
|
552
|
+
if (issue !== null) return `data ${issue.path}: ${issue.message}`;
|
|
553
|
+
const event: StreamEvent = {
|
|
554
|
+
id: updateEventId(record.id, channel.name, channel.version, tenantId, canonical.canonical, revision),
|
|
555
|
+
channel: channel.name,
|
|
556
|
+
channelVersion: channel.version,
|
|
557
|
+
kind: "update",
|
|
558
|
+
data,
|
|
559
|
+
revision,
|
|
560
|
+
receivedAt: record.receivedAt
|
|
561
|
+
};
|
|
562
|
+
const bytes = Buffer.byteLength(JSON.stringify(event)) + FRAME_OVERHEAD_BYTES;
|
|
563
|
+
if (bytes > this.core.limits.maxDataFrameBytes) return "the data frame exceeds maxDataFrameBytes";
|
|
564
|
+
return {
|
|
565
|
+
channel,
|
|
566
|
+
key: routingKey(channel.name, channel.version, tenantId, canonical.canonical),
|
|
567
|
+
frame: { event, bytes, revision, dataHash: sha256Hex(data) }
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Staged diagnostics. Works whether or not the gateway started, so a failing
|
|
573
|
+
* connection profile can be diagnosed; resolves secrets without reporting them.
|
|
574
|
+
*/
|
|
575
|
+
async checkSource(source: SourceRuntimeImpl): Promise<DiagnosticStep[]> {
|
|
576
|
+
const deadline = Date.now() + 10_000;
|
|
577
|
+
if (source.adapter !== null) return source.adapter.check(deadline);
|
|
578
|
+
if (source.config.kind === "fixture") {
|
|
579
|
+
const records = this.#development?.fixtures[source.config.fixtureRef] ?? [];
|
|
580
|
+
return [
|
|
581
|
+
{ stage: "resolve", outcome: "ok", message: `Fixture with ${records.length} records is registered.` },
|
|
582
|
+
{ stage: "connect", outcome: "skipped", message: "Fixture sources have no network connection." },
|
|
583
|
+
{ stage: "tls", outcome: "skipped", message: "Fixture sources have no network connection." },
|
|
584
|
+
{ stage: "authenticate", outcome: "skipped", message: "Fixture sources have no credentials." },
|
|
585
|
+
{ stage: "metadata", outcome: "skipped", message: "The gateway is not running." }
|
|
586
|
+
];
|
|
587
|
+
}
|
|
588
|
+
const profile = this.config.connections[source.config.connectionRef];
|
|
589
|
+
if (profile === undefined) throw notFound(`Unknown connection profile "${source.config.connectionRef}".`);
|
|
590
|
+
let connection: ResolvedKafkaConnection;
|
|
591
|
+
try {
|
|
592
|
+
connection = await resolveKafkaConnection(profile, this.#configDir);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
return [
|
|
595
|
+
{ stage: "resolve", outcome: "failed", message: (error as Error).message },
|
|
596
|
+
...(["connect", "tls", "authenticate", "metadata"] as const).map(stage => ({ stage, outcome: "skipped" as const, message: "Skipped because the profile could not be resolved." }))
|
|
597
|
+
];
|
|
598
|
+
}
|
|
599
|
+
return runKafkaDiagnostics(connection, source.config.topics, deadline);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Diagnostics for every source, for startup failure reports. */
|
|
603
|
+
async checkAllSources(): Promise<{ sourceId: string; steps: DiagnosticStep[] }[]> {
|
|
604
|
+
const results = [];
|
|
605
|
+
for (const source of this.#sources.values()) results.push({ sourceId: source.id, steps: await this.checkSource(source) });
|
|
606
|
+
return results;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Called by adapters after a commit so the trace reflects actual source progress. */
|
|
610
|
+
recordCommit(sourceId: string): void {
|
|
611
|
+
this.core.traces.record({ requestId: newId(), stage: "commit", outcome: "ok", sourceId });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// --- lifecycle internals ---------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
async #doStart(): Promise<{ origin: string; path: string }> {
|
|
617
|
+
const deadline = Date.now() + STARTUP_DEADLINE_MS;
|
|
618
|
+
const started: SourceAdapter[] = [];
|
|
619
|
+
let http: HttpServer | null = null;
|
|
620
|
+
let io: IoServer | null = null;
|
|
621
|
+
try {
|
|
622
|
+
const connections = new Map<string, ResolvedKafkaConnection>();
|
|
623
|
+
for (const source of this.#sources.values()) {
|
|
624
|
+
if (source.config.kind !== "kafka" || connections.has(source.config.connectionRef)) continue;
|
|
625
|
+
const profile = this.config.connections[source.config.connectionRef];
|
|
626
|
+
if (profile === undefined) throw new StreamOtterError("CONFIG_INVALID", { message: `Unknown connection profile ${source.config.connectionRef}.` });
|
|
627
|
+
connections.set(source.config.connectionRef, await resolveKafkaConnection(profile, this.#configDir));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
http = createServer((_request, response) => {
|
|
631
|
+
response.statusCode = 404;
|
|
632
|
+
response.setHeader("Cache-Control", "no-store");
|
|
633
|
+
response.end();
|
|
634
|
+
});
|
|
635
|
+
io = attachSocketIo(http, {
|
|
636
|
+
path: this.config.gateway.path,
|
|
637
|
+
maxControlFrameBytes: this.core.limits.maxControlFrameBytes,
|
|
638
|
+
callbacks: {
|
|
639
|
+
authenticate: input => this.authenticateHandshake(input),
|
|
640
|
+
openSession: (result, transport) => this.openSession(result, transport)
|
|
641
|
+
}
|
|
642
|
+
});
|
|
643
|
+
await new Promise<void>((resolve, reject) => {
|
|
644
|
+
http?.once("error", reject);
|
|
645
|
+
http?.listen(this.config.gateway.port, this.config.gateway.host, () => {
|
|
646
|
+
http?.off("error", reject);
|
|
647
|
+
resolve();
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
for (const source of this.#sources.values()) {
|
|
652
|
+
source.status = "starting";
|
|
653
|
+
source.reason = undefined;
|
|
654
|
+
const adapter = source.config.kind === "fixture"
|
|
655
|
+
? new FixtureSourceAdapter(
|
|
656
|
+
(this.#development?.fixtures[source.config.fixtureRef] ?? []) as readonly FixtureRecord[],
|
|
657
|
+
this.#sink(source),
|
|
658
|
+
() => this.recordCommit(source.id)
|
|
659
|
+
)
|
|
660
|
+
: createKafkaSourceAdapter({
|
|
661
|
+
projectId: this.config.projectId,
|
|
662
|
+
sourceId: source.id,
|
|
663
|
+
source: source.config,
|
|
664
|
+
connection: connections.get(source.config.connectionRef) as ResolvedKafkaConnection,
|
|
665
|
+
sink: this.#sink(source),
|
|
666
|
+
onCommit: () => this.recordCommit(source.id),
|
|
667
|
+
...(this.#internal.beforeCommit === undefined ? {} : { beforeCommit: this.#internal.beforeCommit })
|
|
668
|
+
});
|
|
669
|
+
source.adapter = adapter;
|
|
670
|
+
started.push(adapter);
|
|
671
|
+
}
|
|
672
|
+
const remaining = Math.max(1, deadline - Date.now());
|
|
673
|
+
let timer: NodeJS.Timeout | undefined;
|
|
674
|
+
await Promise.race([
|
|
675
|
+
Promise.all(started.map(adapter => adapter.start())),
|
|
676
|
+
new Promise<never>((_resolve, reject) => {
|
|
677
|
+
timer = setTimeout(() => reject(new StreamOtterError("SOURCE_UNAVAILABLE", {
|
|
678
|
+
message: "Sources did not become ready within the 30-second startup deadline."
|
|
679
|
+
})), remaining);
|
|
680
|
+
})
|
|
681
|
+
]).finally(() => clearTimeout(timer));
|
|
682
|
+
|
|
683
|
+
const address = http.address() as AddressInfo;
|
|
684
|
+
const host = this.config.gateway.host === "0.0.0.0" || this.config.gateway.host === "::" ? "127.0.0.1" : this.config.gateway.host;
|
|
685
|
+
this.#http = http;
|
|
686
|
+
this.#io = io;
|
|
687
|
+
this.#address = { origin: `http://${host.includes(":") ? `[${host}]` : host}:${address.port}`, path: this.config.gateway.path };
|
|
688
|
+
this.#state = "running";
|
|
689
|
+
this.core.logger.info("Gateway started", { origin: this.#address.origin, path: this.#address.path, mode: this.mode });
|
|
690
|
+
return this.#address;
|
|
691
|
+
} catch (error) {
|
|
692
|
+
await Promise.allSettled(started.map(adapter => adapter.stop(Date.now() + 5_000)));
|
|
693
|
+
for (const source of this.#sources.values()) {
|
|
694
|
+
source.adapter = null;
|
|
695
|
+
source.status = "starting";
|
|
696
|
+
source.reason = undefined;
|
|
697
|
+
}
|
|
698
|
+
if (io !== null) await new Promise<void>(resolve => io?.close(() => resolve()));
|
|
699
|
+
else if (http !== null) await new Promise<void>(resolve => http?.close(() => resolve()));
|
|
700
|
+
this.#state = "idle";
|
|
701
|
+
if (error instanceof StreamOtterError) throw error;
|
|
702
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
703
|
+
throw new StreamOtterError("SOURCE_UNAVAILABLE", {
|
|
704
|
+
message: code === "EADDRINUSE" ? `Port ${this.config.gateway.port} is already in use.` : `A source failed to start: ${(error as Error | undefined)?.message ?? String(error)}`
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async #doStop(timeoutMs: number): Promise<void> {
|
|
710
|
+
if (this.#starting !== null) await this.#starting.catch(() => undefined);
|
|
711
|
+
const wasRunning = this.#state === "running";
|
|
712
|
+
this.#state = "stopping";
|
|
713
|
+
this.#stopController.abort();
|
|
714
|
+
for (const session of [...this.#sessions]) session.close();
|
|
715
|
+
const deadline = Date.now() + timeoutMs;
|
|
716
|
+
const work = (async () => {
|
|
717
|
+
await Promise.allSettled([...this.#sources.values()].map(async source => {
|
|
718
|
+
await source.adapter?.stop(deadline);
|
|
719
|
+
source.status = "stopped";
|
|
720
|
+
}));
|
|
721
|
+
await Promise.allSettled(this.#stopCallbacks.map(callback => callback()));
|
|
722
|
+
const io = this.#io;
|
|
723
|
+
if (io !== null) await new Promise<void>(resolve => io.close(() => resolve()));
|
|
724
|
+
})();
|
|
725
|
+
let timer: NodeJS.Timeout | undefined;
|
|
726
|
+
const expired = await Promise.race([
|
|
727
|
+
work.then(() => false),
|
|
728
|
+
new Promise<boolean>(resolve => { timer = setTimeout(() => resolve(true), timeoutMs); })
|
|
729
|
+
]);
|
|
730
|
+
clearTimeout(timer);
|
|
731
|
+
if (expired) {
|
|
732
|
+
this.core.logger.warn("Stop deadline expired; forcing closure without committing incomplete records");
|
|
733
|
+
this.#http?.closeAllConnections();
|
|
734
|
+
}
|
|
735
|
+
this.#state = "stopped";
|
|
736
|
+
if (wasRunning) this.core.logger.info("Gateway stopped");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
#validateRevocation(request: unknown): Revocation {
|
|
740
|
+
const invalid = () => new StreamOtterError("INVALID_REQUEST", { message: "Invalid revocation selector." });
|
|
741
|
+
if (!isPlainObject(request)) throw invalid();
|
|
742
|
+
const text = (value: unknown) => typeof value === "string" && value.length > 0;
|
|
743
|
+
switch (request["kind"]) {
|
|
744
|
+
case "session":
|
|
745
|
+
if (!text(request["tenantId"]) || !text(request["sessionId"])) throw invalid();
|
|
746
|
+
return { kind: "session", tenantId: request["tenantId"] as string, sessionId: request["sessionId"] as string };
|
|
747
|
+
case "subject":
|
|
748
|
+
if (!text(request["tenantId"]) || !text(request["subject"])) throw invalid();
|
|
749
|
+
return { kind: "subject", tenantId: request["tenantId"] as string, subject: request["subject"] as string };
|
|
750
|
+
case "channel": {
|
|
751
|
+
const version = request["channelVersion"];
|
|
752
|
+
if (!text(request["tenantId"]) || !text(request["subject"]) || !text(request["channel"])
|
|
753
|
+
|| typeof version !== "number" || !Number.isSafeInteger(version)) throw invalid();
|
|
754
|
+
const selector: Revocation = {
|
|
755
|
+
kind: "channel",
|
|
756
|
+
tenantId: request["tenantId"] as string,
|
|
757
|
+
subject: request["subject"] as string,
|
|
758
|
+
channel: request["channel"] as string,
|
|
759
|
+
channelVersion: version
|
|
760
|
+
};
|
|
761
|
+
if (request["params"] !== undefined) {
|
|
762
|
+
if (!isPlainObject(request["params"])) throw invalid();
|
|
763
|
+
selector.params = request["params"] as Record<string, string | number | boolean>;
|
|
764
|
+
}
|
|
765
|
+
return selector;
|
|
766
|
+
}
|
|
767
|
+
default:
|
|
768
|
+
throw invalid();
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// --- development and management --------------------------------------------------
|
|
773
|
+
|
|
774
|
+
internals(): GatewayInternals {
|
|
775
|
+
const runtime = this;
|
|
776
|
+
return {
|
|
777
|
+
mode: this.mode,
|
|
778
|
+
config: this.config,
|
|
779
|
+
fingerprint: this.fingerprint,
|
|
780
|
+
limits: this.core.limits,
|
|
781
|
+
logger: this.core.logger,
|
|
782
|
+
get running() { return runtime.#state === "running"; },
|
|
783
|
+
address: () => this.#address,
|
|
784
|
+
health: () => {
|
|
785
|
+
const sources = [...this.#sources.values()].map(source => source.summary());
|
|
786
|
+
return { ready: this.#state === "running" && sources.every(source => source.status === "healthy"), sources };
|
|
787
|
+
},
|
|
788
|
+
sources: () => [...this.#sources.values()].map(source => source.summary()),
|
|
789
|
+
channels: () => Object.entries(this.config.channels).map(([name, channel]) => ({
|
|
790
|
+
name,
|
|
791
|
+
version: channel.version,
|
|
792
|
+
source: channel.source,
|
|
793
|
+
delivery: "state" as const,
|
|
794
|
+
paramsSchema: channel.paramsSchema,
|
|
795
|
+
payloadSchema: channel.payloadSchema
|
|
796
|
+
})),
|
|
797
|
+
traces: query => this.core.traces.page(query),
|
|
798
|
+
checkSource: async sourceId => {
|
|
799
|
+
const source = this.#sources.get(sourceId);
|
|
800
|
+
if (source === undefined) throw notFound(`Unknown source "${sourceId}".`);
|
|
801
|
+
if (this.#activeChecks >= 2) throw new StreamOtterError("OVERLOADED", { message: "At most two source checks may run at once." });
|
|
802
|
+
this.#activeChecks++;
|
|
803
|
+
try {
|
|
804
|
+
return await this.checkSource(source);
|
|
805
|
+
} finally {
|
|
806
|
+
this.#activeChecks--;
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
checkAllSources: () => this.checkAllSources(),
|
|
810
|
+
resumeSource: sourceId => this.resumeSource(sourceId),
|
|
811
|
+
allowDevelopmentOrigin: origin => {
|
|
812
|
+
if (this.mode !== "development") throw new StreamOtterError("FORBIDDEN", { message: "Development origins are rejected in production." });
|
|
813
|
+
this.#allowedOrigins.add(origin);
|
|
814
|
+
},
|
|
815
|
+
developmentPrincipals: () => {
|
|
816
|
+
this.#requireDevelopment();
|
|
817
|
+
return Object.entries(this.#development?.principals ?? {}).map(([ref, principal]) => ({
|
|
818
|
+
ref, tenantId: principal.tenantId, subject: principal.subject
|
|
819
|
+
}));
|
|
820
|
+
},
|
|
821
|
+
createPreviewSession: ref => this.#createPreviewSession(ref),
|
|
822
|
+
disconnectPreviewSession: previewSessionId => {
|
|
823
|
+
this.#requireDevelopment();
|
|
824
|
+
if (!this.#previews.has(previewSessionId)) throw notFound("Unknown preview session.");
|
|
825
|
+
for (const session of [...this.#sessions]) {
|
|
826
|
+
if (session.previewSessionId === previewSessionId) session.close();
|
|
827
|
+
}
|
|
828
|
+
},
|
|
829
|
+
advanceFixture: async (sourceId, count) => {
|
|
830
|
+
this.#requireDevelopment();
|
|
831
|
+
const source = this.#sources.get(sourceId);
|
|
832
|
+
if (source === undefined) throw notFound(`Unknown source "${sourceId}".`);
|
|
833
|
+
if (!(source.adapter instanceof FixtureSourceAdapter)) {
|
|
834
|
+
throw new StreamOtterError("INVALID_REQUEST", { message: "Only fixture sources can be advanced; StreamOtter never publishes to Kafka." });
|
|
835
|
+
}
|
|
836
|
+
if (!Number.isSafeInteger(count) || count < 1 || count > 100) {
|
|
837
|
+
throw new StreamOtterError("INVALID_REQUEST", { message: "count must be an integer from 1 to 100." });
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
return await source.adapter.advance(count);
|
|
841
|
+
} catch (error) {
|
|
842
|
+
if (error instanceof StreamOtterError && error.code === "SOURCE_UNAVAILABLE") throw conflict(error.message);
|
|
843
|
+
throw error;
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
onStop: callback => { this.#stopCallbacks.push(callback); },
|
|
847
|
+
connectionCount: () => this.#sessions.size,
|
|
848
|
+
subscriptionCount: () => [...this.#sessions].reduce((sum, session) => sum + session.subscriptionCount, 0),
|
|
849
|
+
pendingBytes: () => this.core.gatewayBudget.used
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
#requireDevelopment(): void {
|
|
854
|
+
if (this.mode !== "development") throw new StreamOtterError("FORBIDDEN", { message: "Development operations are unavailable in production." });
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
#createPreviewSession(ref: string): { token: string; expiresAt: string; previewSessionId: string } {
|
|
858
|
+
this.#requireDevelopment();
|
|
859
|
+
const principal = typeof ref === "string" && Object.hasOwn(this.#development?.principals ?? {}, ref)
|
|
860
|
+
? this.#development?.principals[ref]
|
|
861
|
+
: undefined;
|
|
862
|
+
if (principal === undefined) throw notFound(`Unknown development principal "${String(ref)}".`);
|
|
863
|
+
const now = Date.now();
|
|
864
|
+
for (const [id, preview] of this.#previews) {
|
|
865
|
+
if (preview.expiresAtMs <= now) this.#previews.delete(id);
|
|
866
|
+
}
|
|
867
|
+
for (const [token, id] of this.#previewTokens) {
|
|
868
|
+
if (!this.#previews.has(id)) this.#previewTokens.delete(token);
|
|
869
|
+
}
|
|
870
|
+
const expiresAtMs = Math.min(now + PREVIEW_TOKEN_TTL_MS, parseUtcTimestamp(principal.expiresAt));
|
|
871
|
+
if (!(expiresAtMs > now)) throw new StreamOtterError("UNAUTHENTICATED", { message: "The development principal has expired." });
|
|
872
|
+
const expiresAt = new Date(expiresAtMs).toISOString();
|
|
873
|
+
const token = `sop_${randomBytes(32).toString("base64url")}`;
|
|
874
|
+
const previewSessionId = newId();
|
|
875
|
+
this.#previews.set(previewSessionId, { principal: freezePrincipal({ ...principal, expiresAt }), expiresAtMs });
|
|
876
|
+
this.#previewTokens.set(token, previewSessionId);
|
|
877
|
+
return { token, expiresAt, previewSessionId };
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Constructs a gateway without opening connections. */
|
|
882
|
+
export function createGatewayRuntime<C extends ChannelMap>(options: GatewayOptions<C>, internal: InternalGatewayOptions = {}): { gateway: Gateway; runtime: GatewayRuntime } {
|
|
883
|
+
const runtime = new GatewayRuntime(options as unknown as GatewayOptions<ChannelMap>, internal);
|
|
884
|
+
const gateway: Gateway = Object.freeze({
|
|
885
|
+
start: () => runtime.start(),
|
|
886
|
+
stop: (stopOptions?: { timeoutMs?: number }) => runtime.stop(stopOptions),
|
|
887
|
+
revoke: (request: Revocation) => runtime.revoke(request),
|
|
888
|
+
resumeSource: async (sourceId: string) => { await runtime.resumeSource(sourceId); }
|
|
889
|
+
});
|
|
890
|
+
internalsRegistry.set(gateway, runtime.internals());
|
|
891
|
+
return { gateway, runtime };
|
|
892
|
+
}
|