@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,80 @@
|
|
|
1
|
+
import { Server } from "socket.io";
|
|
2
|
+
import { EVENTS, HELLO_TIMEOUT_MS } from "@streamotter/contracts";
|
|
3
|
+
const KNOWN_EVENTS = new Set([EVENTS.subscribe, EVENTS.unsubscribe, EVENTS.resync, EVENTS.receipt]);
|
|
4
|
+
class SocketIoConnection {
|
|
5
|
+
#socket;
|
|
6
|
+
constructor(socket) {
|
|
7
|
+
this.#socket = socket;
|
|
8
|
+
}
|
|
9
|
+
sendHello(hello) {
|
|
10
|
+
this.#socket.emit("so:hello", hello);
|
|
11
|
+
}
|
|
12
|
+
sendState(frame) {
|
|
13
|
+
this.#socket.emit("so:state", frame);
|
|
14
|
+
}
|
|
15
|
+
sendData(frame) {
|
|
16
|
+
this.#socket.emit("so:data", frame);
|
|
17
|
+
}
|
|
18
|
+
sendError(frame) {
|
|
19
|
+
this.#socket.emit("so:error", frame);
|
|
20
|
+
}
|
|
21
|
+
close() {
|
|
22
|
+
this.#socket.disconnect(true);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Socket.IO adapter: namespace "/", WebSocket-only transport, connection-state
|
|
27
|
+
* recovery disabled (V1 owns resynchronization), control frames bounded by
|
|
28
|
+
* maxControlFrameBytes. Authentication runs in handshake middleware so failures
|
|
29
|
+
* reach the SDK as structured connect errors.
|
|
30
|
+
*/
|
|
31
|
+
export function attachSocketIo(httpServer, options) {
|
|
32
|
+
const io = new Server(httpServer, {
|
|
33
|
+
path: options.path,
|
|
34
|
+
serveClient: false,
|
|
35
|
+
transports: ["websocket"],
|
|
36
|
+
allowUpgrades: false,
|
|
37
|
+
maxHttpBufferSize: options.maxControlFrameBytes,
|
|
38
|
+
connectTimeout: HELLO_TIMEOUT_MS,
|
|
39
|
+
pingInterval: 25_000,
|
|
40
|
+
pingTimeout: 20_000
|
|
41
|
+
});
|
|
42
|
+
io.use((socket, next) => {
|
|
43
|
+
const origin = socket.handshake.headers.origin;
|
|
44
|
+
options.callbacks.authenticate({ auth: socket.handshake.auth, origin }).then(result => {
|
|
45
|
+
if (!result.ok) {
|
|
46
|
+
const error = new Error(result.error.message);
|
|
47
|
+
error.data = result.error;
|
|
48
|
+
next(error);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
socket.data.handshake = result.value;
|
|
52
|
+
next();
|
|
53
|
+
}, () => {
|
|
54
|
+
const error = new Error("An unexpected error occurred.");
|
|
55
|
+
error.data = { code: "INTERNAL", message: "An unexpected error occurred.", retryable: true, requestId: "" };
|
|
56
|
+
next(error);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
io.on("connection", socket => {
|
|
60
|
+
const handshake = socket.data.handshake;
|
|
61
|
+
if (handshake === undefined) {
|
|
62
|
+
socket.disconnect(true);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const session = options.callbacks.openSession(handshake, new SocketIoConnection(socket));
|
|
66
|
+
const untyped = socket;
|
|
67
|
+
untyped.on(EVENTS.subscribe, (payload, reply) => session.handleSubscribe(payload, reply));
|
|
68
|
+
untyped.on(EVENTS.unsubscribe, (payload, reply) => session.handleUnsubscribe(payload, reply));
|
|
69
|
+
untyped.on(EVENTS.resync, (payload, reply) => session.handleResync(payload, reply));
|
|
70
|
+
untyped.on(EVENTS.receipt, (payload) => session.handleReceipt(payload));
|
|
71
|
+
untyped.onAny((event, ...args) => {
|
|
72
|
+
if (typeof event !== "string" || !KNOWN_EVENTS.has(event))
|
|
73
|
+
session.handleUnknown(String(event), args);
|
|
74
|
+
});
|
|
75
|
+
socket.on("disconnect", () => session.handleTransportClosed());
|
|
76
|
+
session.open();
|
|
77
|
+
});
|
|
78
|
+
return io;
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=socketio.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"socketio.js","sourceRoot":"","sources":["../../src/transport/socketio.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DataFrame, ErrorFrame, Hello, SubscriptionFrame } from "@streamotter/contracts";
|
|
2
|
+
/** Server-side view of one client transport connection. Transport specifics stay in the adapter. */
|
|
3
|
+
export interface ConnectionTransport {
|
|
4
|
+
sendHello(hello: Hello): void;
|
|
5
|
+
sendState(frame: SubscriptionFrame): void;
|
|
6
|
+
sendData(frame: DataFrame): void;
|
|
7
|
+
sendError(frame: ErrorFrame): void;
|
|
8
|
+
close(): void;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/transport/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAE9F,oGAAoG;AACpG,MAAM,WAAW,mBAAmB;IAClC,SAAS,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAC9B,SAAS,CAAC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAC1C,QAAQ,CAAC,KAAK,EAAE,SAAS,GAAG,IAAI,CAAC;IACjC,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IACnC,KAAK,IAAI,IAAI,CAAC;CACf"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/transport/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@streamotter/gateway",
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "StreamOtter Node.js gateway: Kafka and fixture sources, application-owned access handlers, snapshot synchronization, and bounded Socket.IO delivery.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"streamotter",
|
|
7
|
+
"kafka",
|
|
8
|
+
"kafkajs",
|
|
9
|
+
"socket.io",
|
|
10
|
+
"websocket",
|
|
11
|
+
"realtime",
|
|
12
|
+
"live-data",
|
|
13
|
+
"state-sync",
|
|
14
|
+
"gateway",
|
|
15
|
+
"typescript"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/jfricano/StreamOtter/tree/main/packages/gateway#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/jfricano/StreamOtter/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/jfricano/StreamOtter.git",
|
|
24
|
+
"directory": "packages/gateway"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Orca Solutions",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./management": {
|
|
35
|
+
"types": "./dist/management/index.d.ts",
|
|
36
|
+
"default": "./dist/management/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./internals": {
|
|
39
|
+
"types": "./dist/internals.d.ts",
|
|
40
|
+
"default": "./dist/internals.js"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"dist",
|
|
45
|
+
"src"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=24"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"kafkajs": "2.2.4",
|
|
55
|
+
"socket.io": "4.8.3",
|
|
56
|
+
"@streamotter/contracts": "0.1.0-rc.1"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "24.13.6"
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { assertValidProjectConfig, type ChannelMap, type Gateway, type GatewayOptions, type ProjectConfig } from "@streamotter/contracts";
|
|
2
|
+
import { createGatewayRuntime } from "./runtime/gateway.ts";
|
|
3
|
+
|
|
4
|
+
export type * from "@streamotter/contracts";
|
|
5
|
+
export { StreamOtterError, validateProjectConfig } from "@streamotter/contracts";
|
|
6
|
+
export { consoleLogger, silentLogger } from "./runtime/util.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Validates configuration structure and references synchronously. Secret
|
|
10
|
+
* resolution and connectivity belong to gateway startup. Throws CONFIG_INVALID.
|
|
11
|
+
*/
|
|
12
|
+
export function defineProject<C extends ChannelMap>(config: ProjectConfig<C>): ProjectConfig<C> {
|
|
13
|
+
assertValidProjectConfig(config);
|
|
14
|
+
return config;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Constructs a gateway without opening connections; call start() to listen and consume. */
|
|
18
|
+
export function createGateway<C extends ChannelMap>(options: GatewayOptions<C>): Gateway {
|
|
19
|
+
return createGatewayRuntime(options).gateway;
|
|
20
|
+
}
|
package/src/internals.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal access shared by StreamOtter's own CLI, management server, and tests.
|
|
3
|
+
* Not a stable public API; applications should use createGateway().
|
|
4
|
+
*/
|
|
5
|
+
export { createGatewayRuntime, getGatewayInternals, type GatewayInternals, type InternalGatewayOptions } from "./runtime/gateway.ts";
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
4
|
+
import type { AddressInfo } from "node:net";
|
|
5
|
+
import { extname, join, resolve as resolvePath, sep } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
canonicalJsonPretty, CAPABILITIES, DEFAULT_MANAGEMENT_PORT, isPlainObject, streamError, StreamOtterError,
|
|
8
|
+
validateProjectConfig, type ErrorCode, type Gateway, type Json, type Result, type StreamError, type Trace
|
|
9
|
+
} from "@streamotter/contracts";
|
|
10
|
+
import { getGatewayInternals, type GatewayInternals } from "../runtime/gateway.ts";
|
|
11
|
+
import { newId, sha256Hex, TokenBucket } from "../runtime/util.ts";
|
|
12
|
+
|
|
13
|
+
export interface ManagementServerOptions {
|
|
14
|
+
gateway: Gateway;
|
|
15
|
+
/** Loopback by default. */
|
|
16
|
+
host?: string;
|
|
17
|
+
port?: number;
|
|
18
|
+
/** Per-run bearer token; generated when omitted. */
|
|
19
|
+
token?: string;
|
|
20
|
+
/** Built workbench assets to serve from the same origin; null disables the UI. */
|
|
21
|
+
workbenchDir?: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ManagementServer {
|
|
25
|
+
readonly origin: string;
|
|
26
|
+
readonly token: string;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAX_BODY_BYTES = 1_048_576;
|
|
31
|
+
|
|
32
|
+
const STATUS_BY_CODE: Partial<Record<ErrorCode, number>> = {
|
|
33
|
+
INVALID_REQUEST: 400, INVALID_PARAMS: 400, CONFIG_INVALID: 400, UNSUPPORTED_CAPABILITY: 400,
|
|
34
|
+
UNAUTHENTICATED: 401, FORBIDDEN: 403, CHANNEL_NOT_FOUND: 404, TRACE_CURSOR_EXPIRED: 410,
|
|
35
|
+
OVERLOADED: 429, SOURCE_UNAVAILABLE: 503, TIMEOUT: 504
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
39
|
+
".html": "text/html; charset=utf-8",
|
|
40
|
+
".js": "text/javascript; charset=utf-8",
|
|
41
|
+
".css": "text/css; charset=utf-8",
|
|
42
|
+
".svg": "image/svg+xml",
|
|
43
|
+
".png": "image/png",
|
|
44
|
+
".ico": "image/x-icon",
|
|
45
|
+
".json": "application/json; charset=utf-8",
|
|
46
|
+
".map": "application/json; charset=utf-8"
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
class HttpError extends Error {
|
|
50
|
+
readonly status: number;
|
|
51
|
+
readonly error: StreamError;
|
|
52
|
+
|
|
53
|
+
constructor(status: number, code: ErrorCode, message?: string, details?: Readonly<Record<string, Json>>) {
|
|
54
|
+
super(message ?? code);
|
|
55
|
+
this.status = status;
|
|
56
|
+
const options: { message?: string; details?: Readonly<Record<string, Json>> } = {};
|
|
57
|
+
if (message !== undefined) options.message = message;
|
|
58
|
+
if (details !== undefined) options.details = details;
|
|
59
|
+
this.error = streamError(code, options);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tokensEqual(expected: string, provided: string): boolean {
|
|
64
|
+
const a = Buffer.from(expected);
|
|
65
|
+
const b = Buffer.from(provided);
|
|
66
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readJsonBody(request: IncomingMessage): Promise<unknown> {
|
|
70
|
+
const declared = Number(request.headers["content-length"] ?? "0");
|
|
71
|
+
if (declared > MAX_BODY_BYTES) throw new HttpError(413, "INVALID_REQUEST", "The request body exceeds 1 MiB.");
|
|
72
|
+
const chunks: Buffer[] = [];
|
|
73
|
+
let size = 0;
|
|
74
|
+
for await (const chunk of request) {
|
|
75
|
+
size += (chunk as Buffer).length;
|
|
76
|
+
if (size > MAX_BODY_BYTES) throw new HttpError(413, "INVALID_REQUEST", "The request body exceeds 1 MiB.");
|
|
77
|
+
chunks.push(chunk as Buffer);
|
|
78
|
+
}
|
|
79
|
+
if (size === 0) throw new HttpError(400, "INVALID_REQUEST", "A JSON body is required.");
|
|
80
|
+
const type = request.headers["content-type"] ?? "";
|
|
81
|
+
if (!/^application\/json\b/i.test(type)) throw new HttpError(400, "INVALID_REQUEST", "Content-Type must be application/json.");
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
84
|
+
} catch {
|
|
85
|
+
throw new HttpError(400, "INVALID_REQUEST", "The body is not valid JSON.");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Requires an object with exactly the listed keys (optional keys may be absent). */
|
|
90
|
+
function shape(value: unknown, required: readonly string[], optional: readonly string[] = []): Record<string, unknown> {
|
|
91
|
+
if (!isPlainObject(value)) throw new HttpError(400, "INVALID_REQUEST", "The body must be a JSON object.");
|
|
92
|
+
for (const key of Object.keys(value)) {
|
|
93
|
+
if (!required.includes(key) && !optional.includes(key)) throw new HttpError(400, "INVALID_REQUEST", `Unknown field "${key.slice(0, 64)}".`);
|
|
94
|
+
}
|
|
95
|
+
for (const key of required) {
|
|
96
|
+
if (!Object.hasOwn(value, key)) throw new HttpError(400, "INVALID_REQUEST", `"${key}" is required.`);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requireString(value: unknown, name: string): string {
|
|
102
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 256) throw new HttpError(400, "INVALID_REQUEST", `${name} must be a non-empty string.`);
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function mapError(error: unknown): { status: number; error: StreamError } {
|
|
107
|
+
if (error instanceof HttpError) return { status: error.status, error: error.error };
|
|
108
|
+
if (error instanceof StreamOtterError) {
|
|
109
|
+
const status = typeof error.details?.["status"] === "number" ? error.details["status"] : STATUS_BY_CODE[error.code] ?? 500;
|
|
110
|
+
const { status: _omit, ...details } = (error.details ?? {}) as Record<string, Json>;
|
|
111
|
+
const options: { message: string; retryable: boolean; details?: Readonly<Record<string, Json>> } = { message: error.message, retryable: error.retryable };
|
|
112
|
+
if (Object.keys(details).length > 0) options.details = details;
|
|
113
|
+
return { status, error: streamError(error.code, options) };
|
|
114
|
+
}
|
|
115
|
+
return { status: 500, error: streamError("INTERNAL") };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Local development management API and workbench host. Every /management/v1
|
|
120
|
+
* operation requires the per-run bearer token. Browser requests must carry the
|
|
121
|
+
* exact workbench Origin (or, for same-origin GETs, a same-origin Referer). No
|
|
122
|
+
* CORS is ever granted. Refuses to start for production gateways.
|
|
123
|
+
*/
|
|
124
|
+
export async function startManagementServer(options: ManagementServerOptions): Promise<ManagementServer> {
|
|
125
|
+
const internals = getGatewayInternals(options.gateway);
|
|
126
|
+
if (internals.mode !== "development") {
|
|
127
|
+
throw new StreamOtterError("FORBIDDEN", { message: "The management API is only available in development mode." });
|
|
128
|
+
}
|
|
129
|
+
const token = options.token ?? randomBytes(24).toString("base64url");
|
|
130
|
+
const host = options.host ?? "127.0.0.1";
|
|
131
|
+
const workbenchDir = options.workbenchDir === undefined || options.workbenchDir === null ? null : await realpath(options.workbenchDir).catch(() => null);
|
|
132
|
+
const bucket = new TokenBucket(100, 200);
|
|
133
|
+
let origin = "";
|
|
134
|
+
|
|
135
|
+
const server = createServer((request, response) => {
|
|
136
|
+
const requestId = newId();
|
|
137
|
+
response.setHeader("X-Request-Id", requestId);
|
|
138
|
+
response.setHeader("Cache-Control", "no-store");
|
|
139
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
140
|
+
response.setHeader("Referrer-Policy", "same-origin");
|
|
141
|
+
response.setHeader("X-Frame-Options", "DENY");
|
|
142
|
+
handle(request, response, requestId).catch(error => {
|
|
143
|
+
const mapped = mapError(error);
|
|
144
|
+
if (mapped.status === 500) internals.logger.error("Management request failed", { requestId, error: String((error as Error)?.name ?? "Error") });
|
|
145
|
+
send(response, mapped.status, { ok: false, requestId, error: { ...mapped.error, requestId } });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
function send(response: ServerResponse, status: number, body: Result<unknown>): void {
|
|
150
|
+
if (response.headersSent) {
|
|
151
|
+
response.end();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
response.statusCode = status;
|
|
155
|
+
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
156
|
+
response.end(JSON.stringify(body));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function checkBrowserOrigin(request: IncomingMessage): void {
|
|
160
|
+
const requestOrigin = request.headers.origin;
|
|
161
|
+
if (requestOrigin !== undefined) {
|
|
162
|
+
if (requestOrigin !== origin) throw new HttpError(403, "FORBIDDEN", "Cross-origin management requests are not allowed.");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const referer = request.headers.referer;
|
|
166
|
+
if (referer !== undefined) {
|
|
167
|
+
if (request.method !== "GET" || !(referer === origin || referer.startsWith(`${origin}/`))) {
|
|
168
|
+
throw new HttpError(403, "FORBIDDEN", "Cross-origin management requests are not allowed.");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function query(url: URL, allowed: readonly string[]): Record<string, string> {
|
|
174
|
+
const values: Record<string, string> = {};
|
|
175
|
+
for (const [key, value] of url.searchParams) {
|
|
176
|
+
if (!allowed.includes(key)) throw new HttpError(400, "INVALID_REQUEST", `Unknown query parameter "${key.slice(0, 64)}".`);
|
|
177
|
+
if (Object.hasOwn(values, key)) throw new HttpError(400, "INVALID_REQUEST", `Duplicate query parameter "${key.slice(0, 64)}".`);
|
|
178
|
+
values[key] = value;
|
|
179
|
+
}
|
|
180
|
+
return values;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function handle(request: IncomingMessage, response: ServerResponse, requestId: string): Promise<void> {
|
|
184
|
+
const url = new URL(request.url ?? "/", "http://management.invalid");
|
|
185
|
+
if (!url.pathname.startsWith("/management/")) {
|
|
186
|
+
await serveStatic(request, response, url.pathname);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
checkBrowserOrigin(request);
|
|
190
|
+
const authorization = request.headers.authorization ?? "";
|
|
191
|
+
const provided = /^Bearer (.+)$/.exec(authorization)?.[1] ?? "";
|
|
192
|
+
if (!tokensEqual(token, provided)) throw new HttpError(401, "UNAUTHENTICATED", "A valid management bearer token is required.");
|
|
193
|
+
if (!bucket.take()) throw new HttpError(429, "OVERLOADED", "Too many management requests.");
|
|
194
|
+
const route = `${request.method ?? "GET"} ${url.pathname}`;
|
|
195
|
+
const ok = (data: unknown) => send(response, 200, { ok: true, requestId, data });
|
|
196
|
+
const noQuery = () => query(url, []);
|
|
197
|
+
|
|
198
|
+
switch (route) {
|
|
199
|
+
case "GET /management/v1/capabilities":
|
|
200
|
+
noQuery();
|
|
201
|
+
return ok(CAPABILITIES);
|
|
202
|
+
case "GET /management/v1/health":
|
|
203
|
+
noQuery();
|
|
204
|
+
return ok(internals.health());
|
|
205
|
+
case "GET /management/v1/sources":
|
|
206
|
+
noQuery();
|
|
207
|
+
return ok({ items: internals.sources() });
|
|
208
|
+
case "GET /management/v1/channels":
|
|
209
|
+
noQuery();
|
|
210
|
+
return ok({ items: internals.channels() });
|
|
211
|
+
case "GET /management/v1/config":
|
|
212
|
+
noQuery();
|
|
213
|
+
return ok({ config: internals.config, fingerprint: internals.fingerprint });
|
|
214
|
+
case "GET /management/v1/traces": {
|
|
215
|
+
const q = query(url, ["limit", "cursor", "sourceId", "channel", "outcome"]);
|
|
216
|
+
let limit = 100;
|
|
217
|
+
if (q["limit"] !== undefined) {
|
|
218
|
+
if (!/^\d{1,3}$/.test(q["limit"])) throw new HttpError(400, "INVALID_REQUEST", "limit must be an integer from 1 to 500.");
|
|
219
|
+
limit = Number(q["limit"]);
|
|
220
|
+
if (limit < 1 || limit > 500) throw new HttpError(400, "INVALID_REQUEST", "limit must be an integer from 1 to 500.");
|
|
221
|
+
}
|
|
222
|
+
const outcome = q["outcome"];
|
|
223
|
+
if (outcome !== undefined && !["ok", "filtered", "rejected", "failed"].includes(outcome)) {
|
|
224
|
+
throw new HttpError(400, "INVALID_REQUEST", "outcome must be ok, filtered, rejected, or failed.");
|
|
225
|
+
}
|
|
226
|
+
const page = internals.traces({
|
|
227
|
+
limit,
|
|
228
|
+
...(q["cursor"] === undefined ? {} : { cursor: q["cursor"] }),
|
|
229
|
+
...(q["sourceId"] === undefined ? {} : { sourceId: q["sourceId"] }),
|
|
230
|
+
...(q["channel"] === undefined ? {} : { channel: q["channel"] }),
|
|
231
|
+
...(outcome === undefined ? {} : { outcome: outcome as Trace["outcome"] })
|
|
232
|
+
});
|
|
233
|
+
return ok(page);
|
|
234
|
+
}
|
|
235
|
+
case "GET /management/v1/dev/principals":
|
|
236
|
+
noQuery();
|
|
237
|
+
return ok({ items: internals.developmentPrincipals() });
|
|
238
|
+
default:
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (request.method !== "POST") throw new HttpError(404, "INVALID_REQUEST", "Unknown management route.");
|
|
243
|
+
noQuery();
|
|
244
|
+
const body = await readJsonBody(request);
|
|
245
|
+
switch (route) {
|
|
246
|
+
case "POST /management/v1/source-checks": {
|
|
247
|
+
const sourceId = requireString(shape(body, ["sourceId"])["sourceId"], "sourceId");
|
|
248
|
+
return ok({ steps: await internals.checkSource(sourceId) });
|
|
249
|
+
}
|
|
250
|
+
case "POST /management/v1/config/validate": {
|
|
251
|
+
const { config } = shape(body, ["config"]);
|
|
252
|
+
return ok(validateProjectConfig(config));
|
|
253
|
+
}
|
|
254
|
+
case "POST /management/v1/config/export": {
|
|
255
|
+
const { config } = shape(body, ["config"]);
|
|
256
|
+
const validation = validateProjectConfig(config);
|
|
257
|
+
if (!validation.valid) {
|
|
258
|
+
throw new HttpError(400, "CONFIG_INVALID", "The configuration is invalid and was not exported.", {
|
|
259
|
+
issues: validation.issues.map(issue => ({ path: issue.path, code: issue.code, message: issue.message }))
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return ok({ filename: "streamotter.json", content: canonicalJsonPretty(config), fingerprint: sha256Hex(config) });
|
|
263
|
+
}
|
|
264
|
+
case "POST /management/v1/sources/resume": {
|
|
265
|
+
const sourceId = requireString(shape(body, ["sourceId"])["sourceId"], "sourceId");
|
|
266
|
+
return ok(await internals.resumeSource(sourceId));
|
|
267
|
+
}
|
|
268
|
+
case "POST /management/v1/preview-sessions": {
|
|
269
|
+
const ref = requireString(shape(body, ["fixturePrincipalRef"])["fixturePrincipalRef"], "fixturePrincipalRef");
|
|
270
|
+
return ok(internals.createPreviewSession(ref));
|
|
271
|
+
}
|
|
272
|
+
case "POST /management/v1/dev/fixtures/advance": {
|
|
273
|
+
const fields = shape(body, ["sourceId", "count"]);
|
|
274
|
+
const sourceId = requireString(fields["sourceId"], "sourceId");
|
|
275
|
+
const count = fields["count"];
|
|
276
|
+
if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 1 || count > 100) {
|
|
277
|
+
throw new HttpError(400, "INVALID_REQUEST", "count must be an integer from 1 to 100.");
|
|
278
|
+
}
|
|
279
|
+
return ok({ advanced: await internals.advanceFixture(sourceId, count) });
|
|
280
|
+
}
|
|
281
|
+
case "POST /management/v1/dev/disconnect": {
|
|
282
|
+
const previewSessionId = requireString(shape(body, ["previewSessionId"])["previewSessionId"], "previewSessionId");
|
|
283
|
+
internals.disconnectPreviewSession(previewSessionId);
|
|
284
|
+
return ok(null);
|
|
285
|
+
}
|
|
286
|
+
default:
|
|
287
|
+
throw new HttpError(404, "INVALID_REQUEST", "Unknown management route.");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function serveStatic(request: IncomingMessage, response: ServerResponse, pathname: string): Promise<void> {
|
|
292
|
+
if (workbenchDir === null || (request.method !== "GET" && request.method !== "HEAD")) {
|
|
293
|
+
response.statusCode = 404;
|
|
294
|
+
response.end();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
let decoded: string;
|
|
298
|
+
try {
|
|
299
|
+
decoded = decodeURIComponent(pathname);
|
|
300
|
+
} catch {
|
|
301
|
+
response.statusCode = 400;
|
|
302
|
+
response.end();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
|
|
306
|
+
const file = resolvePath(workbenchDir, relative);
|
|
307
|
+
if (!file.startsWith(workbenchDir + sep) || relative.includes("\0")) {
|
|
308
|
+
response.statusCode = 404;
|
|
309
|
+
response.end();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
let target = file;
|
|
313
|
+
try {
|
|
314
|
+
const info = await stat(target);
|
|
315
|
+
if (info.isDirectory()) target = join(target, "index.html");
|
|
316
|
+
const real = await realpath(target);
|
|
317
|
+
if (!real.startsWith(workbenchDir + sep)) throw new Error("outside");
|
|
318
|
+
let content: Buffer | string = await readFile(real);
|
|
319
|
+
if (extname(real) === ".html") {
|
|
320
|
+
// Tell the workbench where the gateway listens (not secret; the token is never embedded).
|
|
321
|
+
const address = internals.address();
|
|
322
|
+
const escape = (value: string) => value.replace(/[&"<>]/g, character => `&#${character.charCodeAt(0)};`);
|
|
323
|
+
const meta = address === null ? "" :
|
|
324
|
+
`<meta name="streamotter-gateway-origin" content="${escape(address.origin)}"><meta name="streamotter-gateway-path" content="${escape(address.path)}">`;
|
|
325
|
+
content = content.toString("utf8").replace("</head>", `${meta}</head>`);
|
|
326
|
+
}
|
|
327
|
+
response.statusCode = 200;
|
|
328
|
+
response.setHeader("Content-Type", CONTENT_TYPES[extname(real)] ?? "application/octet-stream");
|
|
329
|
+
const gatewayOrigin = internals.address()?.origin ?? "";
|
|
330
|
+
const socketOrigin = gatewayOrigin.replace(/^http/, "ws");
|
|
331
|
+
response.setHeader("Content-Security-Policy", [
|
|
332
|
+
"default-src 'self'",
|
|
333
|
+
`connect-src 'self' ${gatewayOrigin} ${socketOrigin}`.trim(),
|
|
334
|
+
"img-src 'self' data:",
|
|
335
|
+
"style-src 'self'",
|
|
336
|
+
"script-src 'self'",
|
|
337
|
+
"frame-ancestors 'none'",
|
|
338
|
+
"base-uri 'none'",
|
|
339
|
+
"form-action 'none'"
|
|
340
|
+
].join("; "));
|
|
341
|
+
response.end(request.method === "HEAD" ? undefined : content);
|
|
342
|
+
} catch {
|
|
343
|
+
response.statusCode = 404;
|
|
344
|
+
response.end();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await new Promise<void>((resolve, reject) => {
|
|
349
|
+
server.once("error", reject);
|
|
350
|
+
server.listen(options.port ?? DEFAULT_MANAGEMENT_PORT, host, () => {
|
|
351
|
+
server.off("error", reject);
|
|
352
|
+
resolve();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
const address = server.address() as AddressInfo;
|
|
356
|
+
origin = `http://${host.includes(":") ? `[${host}]` : host}:${address.port}`;
|
|
357
|
+
internals.allowDevelopmentOrigin(origin);
|
|
358
|
+
|
|
359
|
+
let closing: Promise<void> | null = null;
|
|
360
|
+
const close = () => {
|
|
361
|
+
closing ??= new Promise<void>(resolve => {
|
|
362
|
+
server.close(() => resolve());
|
|
363
|
+
server.closeAllConnections();
|
|
364
|
+
});
|
|
365
|
+
return closing;
|
|
366
|
+
};
|
|
367
|
+
internals.onStop(close);
|
|
368
|
+
return { origin, token, close };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export type { GatewayInternals };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hierarchical byte budget: subscription → connection → gateway. A reservation
|
|
3
|
+
* succeeds only if every level has room, and is applied to every level.
|
|
4
|
+
*/
|
|
5
|
+
export class ByteBudget {
|
|
6
|
+
readonly limit: number;
|
|
7
|
+
readonly parent: ByteBudget | null;
|
|
8
|
+
#used = 0;
|
|
9
|
+
|
|
10
|
+
constructor(limit: number, parent: ByteBudget | null = null) {
|
|
11
|
+
this.limit = limit;
|
|
12
|
+
this.parent = parent;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
get used(): number {
|
|
16
|
+
return this.#used;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
tryReserve(bytes: number): boolean {
|
|
20
|
+
for (let node: ByteBudget | null = this; node !== null; node = node.parent) {
|
|
21
|
+
if (node.#used + bytes > node.limit) return false;
|
|
22
|
+
}
|
|
23
|
+
for (let node: ByteBudget | null = this; node !== null; node = node.parent) node.#used += bytes;
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
release(bytes: number): void {
|
|
28
|
+
for (let node: ByteBudget | null = this; node !== null; node = node.parent) {
|
|
29
|
+
node.#used = Math.max(0, node.#used - bytes);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Per-subscription budget: a frame count plus a byte budget chained to its connection. */
|
|
35
|
+
export class SubscriptionBudget {
|
|
36
|
+
readonly bytes: ByteBudget;
|
|
37
|
+
readonly maxFrames: number;
|
|
38
|
+
#frames = 0;
|
|
39
|
+
|
|
40
|
+
constructor(maxFrames: number, maxBytes: number, parent: ByteBudget) {
|
|
41
|
+
this.maxFrames = maxFrames;
|
|
42
|
+
this.bytes = new ByteBudget(maxBytes, parent);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get frames(): number {
|
|
46
|
+
return this.#frames;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
tryReserve(bytes: number): boolean {
|
|
50
|
+
if (this.#frames + 1 > this.maxFrames) return false;
|
|
51
|
+
if (!this.bytes.tryReserve(bytes)) return false;
|
|
52
|
+
this.#frames++;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
release(bytes: number): void {
|
|
57
|
+
this.#frames = Math.max(0, this.#frames - 1);
|
|
58
|
+
this.bytes.release(bytes);
|
|
59
|
+
}
|
|
60
|
+
}
|