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