trustgraph/ts/packages/flow/src/gateway/server.ts

278 lines
8.7 KiB
TypeScript
Raw Normal View History

2026-04-05 21:09:33 -05:00
/**
* API Gateway HTTP + WebSocket server.
*
* Replaces the Python aiohttp gateway with Fastify.
2026-04-05 22:44:45 -05:00
* Uses the Mux class for WebSocket multiplexing (queue-based request
* buffering, concurrency control, proper task lifecycle).
2026-04-05 21:09:33 -05:00
*
* Python reference: trustgraph-flow/trustgraph/gateway/service.py
*/
import Fastify from "fastify";
import websocketPlugin from "@fastify/websocket";
2026-05-12 08:06:58 -05:00
import { Config, Effect } from "effect";
import * as O from "effect/Option";
import { errorMessage, optionalStringConfig, registry, toTgError } from "@trustgraph/base";
2026-04-05 21:09:33 -05:00
import { DispatcherManager } from "./dispatch/manager.js";
2026-04-05 22:44:45 -05:00
import { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js";
2026-04-05 21:09:33 -05:00
export interface GatewayConfig {
port: number;
metricsPort: number;
secret?: string;
natsUrl?: string;
}
export async function createGateway(config: GatewayConfig) {
const app = Fastify({ logger: true });
await app.register(websocketPlugin);
const dispatcher = new DispatcherManager(config);
await dispatcher.start();
// Authentication middleware
app.addHook("onRequest", async (request, reply) => {
if (request.url === "/api/v1/metrics") return;
2026-04-05 22:44:45 -05:00
if (request.url.startsWith("/api/v1/socket")) return; // Socket auth via query param
2026-04-05 21:09:33 -05:00
2026-05-12 08:06:58 -05:00
if (config.secret !== undefined && config.secret.length > 0) {
2026-04-05 21:09:33 -05:00
const auth = request.headers.authorization;
2026-05-12 08:06:58 -05:00
if (auth === undefined || auth !== `Bearer ${config.secret}`) {
2026-04-05 21:09:33 -05:00
reply.code(401).send({ error: "Unauthorized" });
}
}
});
2026-04-05 22:44:45 -05:00
// REST endpoint: POST /api/v1/:kind (global services)
2026-04-05 21:09:33 -05:00
app.post<{ Params: { kind: string } }>("/api/v1/:kind", async (request, reply) => {
const { kind } = request.params;
const body = request.body as Record<string, unknown>;
try {
const result = await dispatcher.dispatchGlobalService(kind, body) as Record<string, unknown>;
const err = result?.error as { type?: string; message?: string } | undefined;
2026-05-12 08:06:58 -05:00
if (err !== undefined) {
const statusCode = err.type === "not-found" ? 404 : 400;
return reply.code(statusCode).send(result);
}
2026-04-05 21:09:33 -05:00
return result;
} catch (err) {
2026-05-12 08:06:58 -05:00
reply.code(500).send({ error: toTgError(err) });
2026-04-05 21:09:33 -05:00
}
});
2026-04-05 22:44:45 -05:00
// REST endpoint: POST /api/v1/flow/:flow/service/:kind (flow-scoped services)
2026-04-05 21:09:33 -05:00
app.post<{ Params: { flow: string; kind: string } }>(
"/api/v1/flow/:flow/service/:kind",
async (request, reply) => {
const { flow, kind } = request.params;
const body = request.body as Record<string, unknown>;
try {
const result = await dispatcher.dispatchFlowService(flow, kind, body) as Record<string, unknown>;
const err = result?.error as { type?: string; message?: string } | undefined;
2026-05-12 08:06:58 -05:00
if (err !== undefined) {
const statusCode = err.type === "not-found" ? 404 : 400;
return reply.code(statusCode).send(result);
}
2026-04-05 21:09:33 -05:00
return result;
} catch (err) {
2026-05-12 08:06:58 -05:00
reply.code(500).send({ error: toTgError(err) });
2026-04-05 21:09:33 -05:00
}
},
);
// REST endpoint: POST /api/v1/flow/:flow/load (trigger document processing)
app.post<{ Params: { flow: string } }>(
"/api/v1/flow/:flow/load",
async (request, reply) => {
const { flow } = request.params;
const body = request.body as {
documentId?: string;
user?: string;
collection?: string;
};
2026-05-12 08:06:58 -05:00
if (body.documentId === undefined || body.documentId.length === 0) {
return reply.code(400).send({
error: { type: "bad-request", message: "documentId is required" },
});
}
try {
const user = body.user ?? "default";
const collection = body.collection ?? "default";
const documentId = body.documentId;
// Publish Document message to the decode-input topic
const topic = "tg.flow.document";
const metadata = {
id: `load-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
root: documentId,
user,
collection,
};
await dispatcher.publishToTopic(topic, { metadata, documentId });
return { status: "processing", documentId, flow };
} catch (err) {
reply.code(500).send({
2026-05-12 08:06:58 -05:00
error: toTgError(err),
});
}
},
);
2026-04-05 21:09:33 -05:00
// WebSocket endpoint: /api/v1/socket
2026-04-05 22:44:45 -05:00
// Uses Mux for queue-based request buffering and concurrency control.
2026-04-05 21:09:33 -05:00
app.get("/api/v1/socket", { websocket: true }, (socket, request) => {
// Auth via query param
const url = new URL(request.url, `http://${request.headers.host}`);
const token = url.searchParams.get("token");
2026-05-12 08:06:58 -05:00
if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) {
2026-04-05 21:09:33 -05:00
socket.close(4001, "Unauthorized");
return;
}
2026-04-05 22:44:45 -05:00
// Build the MuxHandler that dispatches to the DispatcherManager
const handler: MuxHandler = async (muxReq, respond) => {
2026-05-12 08:06:58 -05:00
if (muxReq.flow !== undefined && muxReq.flow.length > 0) {
2026-04-05 22:44:45 -05:00
await dispatcher.dispatchFlowServiceStreaming(
muxReq.flow,
muxReq.service,
muxReq.request,
respond,
);
} else {
await dispatcher.dispatchGlobalServiceStreaming(
muxReq.service,
muxReq.request,
respond,
);
}
};
const mux = new Mux(handler);
2026-04-05 21:09:33 -05:00
2026-04-05 22:44:45 -05:00
// Start the Mux run loop — sends responses back over the socket
const runPromise = mux.run((data) => {
// Only send if the socket is still open (readyState 1 = OPEN)
if (socket.readyState === 1) {
socket.send(data);
}
});
// Incoming messages get queued into the Mux
socket.on("message", (data) => {
try {
const msg = JSON.parse(data.toString()) as {
id?: string;
service?: string;
flow?: string;
request?: Record<string, unknown>;
2026-04-05 21:09:33 -05:00
};
2026-05-12 08:06:58 -05:00
if (
msg.id === undefined ||
msg.id.length === 0 ||
msg.service === undefined ||
msg.service.length === 0 ||
msg.request === undefined
) {
2026-04-05 22:44:45 -05:00
socket.send(
JSON.stringify({
id: msg.id ?? null,
error: { type: "bad-request", message: "Missing id, service, or request" },
complete: true,
}),
);
return;
2026-04-05 21:09:33 -05:00
}
2026-04-05 22:44:45 -05:00
const muxReq: MuxRequest = {
id: msg.id,
service: msg.service,
request: msg.request,
2026-05-12 08:06:58 -05:00
...(msg.flow !== undefined ? { flow: msg.flow } : {}),
2026-04-05 22:44:45 -05:00
};
mux.receive(muxReq);
2026-04-05 21:09:33 -05:00
} catch (err) {
socket.send(
JSON.stringify({
2026-05-12 08:06:58 -05:00
error: { type: "parse-error", message: errorMessage(err) },
2026-04-05 21:09:33 -05:00
complete: true,
}),
);
}
});
socket.on("close", () => {
2026-04-05 22:44:45 -05:00
mux.stop();
});
socket.on("error", () => {
mux.stop();
});
// Ensure runPromise errors don't go unhandled
runPromise.catch((err) => {
console.error("[Gateway] Mux run loop error:", err);
mux.stop();
if (socket.readyState === 1) {
socket.close(1011, "Internal server error");
}
2026-04-05 21:09:33 -05:00
});
});
2026-04-05 22:44:45 -05:00
// Metrics endpoint — returns Prometheus metrics from prom-client
app.get("/api/v1/metrics", async (_, reply) => {
reply.header("content-type", registry.contentType);
2026-04-05 21:09:33 -05:00
return registry.metrics();
});
return {
start: () => app.listen({ port: config.port, host: "0.0.0.0" }),
stop: async () => {
await app.close();
await dispatcher.stop();
},
};
}
export async function run(): Promise<void> {
2026-05-12 08:06:58 -05:00
await Effect.runPromise(program);
2026-04-05 21:09:33 -05:00
}
2026-05-12 08:06:58 -05:00
export const loadGatewayConfig = Effect.fn("loadGatewayConfig")(function* () {
const secret = O.getOrUndefined(yield* Config.string("GATEWAY_SECRET").pipe(Config.option));
const natsUrl = yield* optionalStringConfig("NATS_URL");
const port = yield* Config.number("GATEWAY_PORT").pipe(Config.withDefault(8088));
const metricsPort = yield* Config.number("METRICS_PORT").pipe(Config.withDefault(8000));
return {
port,
metricsPort,
...(secret !== undefined ? { secret } : {}),
...(natsUrl !== undefined ? { natsUrl } : {}),
} satisfies GatewayConfig;
});
export const program = Effect.scoped(
Effect.gen(function* () {
const config = yield* loadGatewayConfig();
const gateway = yield* Effect.promise(() => createGateway(config)).pipe(Effect.orDie);
yield* Effect.addFinalizer(() => Effect.promise(() => gateway.stop()).pipe(Effect.orDie));
yield* Effect.promise(() => gateway.start()).pipe(
Effect.orDie,
Effect.withSpan("trustgraph.gateway.start", {
attributes: {
"trustgraph.gateway.port": config.port,
},
}),
);
yield* Effect.log(`[Gateway] Listening on port ${config.port}`);
return yield* Effect.never;
}),
);