mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
fix(ts): close effect native review blockers
This commit is contained in:
parent
b6759e75df
commit
a26463afc1
13 changed files with 438 additions and 528 deletions
214
ts/packages/flow/src/__tests__/gateway-routes.test.ts
Normal file
214
ts/packages/flow/src/__tests__/gateway-routes.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { Effect, Exit, Scope } from "effect";
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
||||
import type { DispatcherManager } from "../gateway/dispatch/manager.js";
|
||||
import type { GatewayRpcServer } from "../gateway/rpc-server.js";
|
||||
import { makeGatewayRoutes, type GatewayConfig } from "../gateway/server.js";
|
||||
|
||||
interface DispatchCall {
|
||||
readonly scope: "global" | "flow";
|
||||
readonly flow?: string;
|
||||
readonly kind: string;
|
||||
readonly request: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PublishCall {
|
||||
readonly topic: string;
|
||||
readonly message: unknown;
|
||||
readonly id?: string;
|
||||
}
|
||||
|
||||
interface RouteCalls {
|
||||
readonly dispatches: Array<DispatchCall>;
|
||||
readonly publishes: Array<PublishCall>;
|
||||
rpcCalls: number;
|
||||
}
|
||||
|
||||
const makeFakeDispatcher = (
|
||||
calls: RouteCalls,
|
||||
response: unknown = { ok: true },
|
||||
): DispatcherManager =>
|
||||
({
|
||||
start: Effect.void,
|
||||
stop: Effect.void,
|
||||
dispatchGlobalService: (kind, request) =>
|
||||
Effect.sync(() => {
|
||||
calls.dispatches.push({ scope: "global", kind, request });
|
||||
return response;
|
||||
}),
|
||||
dispatchGlobalServiceStreaming: () => Effect.void,
|
||||
dispatchFlowService: (flow, kind, request) =>
|
||||
Effect.sync(() => {
|
||||
calls.dispatches.push({ scope: "flow", flow, kind, request });
|
||||
return response;
|
||||
}),
|
||||
dispatchFlowServiceStreaming: () => Effect.void,
|
||||
publishToTopic: (topic, message, id) =>
|
||||
Effect.sync(() => {
|
||||
calls.publishes.push(id === undefined ? { topic, message } : { topic, message, id });
|
||||
}),
|
||||
}) as DispatcherManager;
|
||||
|
||||
const makeRequest = (
|
||||
path: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
) =>
|
||||
new Request(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const makeRouteHarness = (
|
||||
config: GatewayConfig = { port: 0, metricsPort: 0, secret: "secret" },
|
||||
dispatchResponse?: unknown,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const rpcScope = yield* Scope.make();
|
||||
yield* Effect.addFinalizer(() => Scope.close(rpcScope, Exit.void));
|
||||
|
||||
const calls: RouteCalls = {
|
||||
dispatches: [],
|
||||
publishes: [],
|
||||
rpcCalls: 0,
|
||||
};
|
||||
const dispatcher = makeFakeDispatcher(calls, dispatchResponse);
|
||||
const rpcServer: GatewayRpcServer = {
|
||||
httpEffect: Effect.sync(() => {
|
||||
calls.rpcCalls += 1;
|
||||
return HttpServerResponse.text("rpc ok");
|
||||
}),
|
||||
};
|
||||
const { handler, dispose } = HttpRouter.toWebHandler(
|
||||
makeGatewayRoutes(config, dispatcher, rpcServer, rpcScope),
|
||||
{ disableLogger: true },
|
||||
);
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => dispose()));
|
||||
|
||||
return { calls, handler };
|
||||
});
|
||||
|
||||
describe("gateway HTTP routes", () => {
|
||||
it.effect(
|
||||
"rejects protected dispatch routes without bearer auth before dispatching",
|
||||
Effect.fnUntraced(function* () {
|
||||
const { calls, handler } = yield* makeRouteHarness();
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(makeRequest("/api/v1/config", { operation: "get" }))
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(calls.dispatches).toEqual([]);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"returns bad request for malformed and non-object JSON bodies",
|
||||
Effect.fnUntraced(function* () {
|
||||
const { calls, handler } = yield* makeRouteHarness();
|
||||
const headers = { authorization: "Bearer secret" };
|
||||
|
||||
const malformed = yield* Effect.promise(() =>
|
||||
handler(makeRequest("/api/v1/config", "{", headers))
|
||||
);
|
||||
const malformedBody = yield* Effect.promise(() => malformed.json()) as { error?: { message?: string } };
|
||||
|
||||
const arrayBody = yield* Effect.promise(() =>
|
||||
handler(makeRequest("/api/v1/config", [], headers))
|
||||
);
|
||||
const arrayBodyJson = yield* Effect.promise(() => arrayBody.json()) as { error?: { message?: string } };
|
||||
|
||||
expect(malformed.status).toBe(400);
|
||||
expect(malformedBody.error?.message).toBe("request body must be valid JSON");
|
||||
expect(arrayBody.status).toBe(400);
|
||||
expect(arrayBodyJson.error?.message).toBe("request body must be a JSON object");
|
||||
expect(calls.dispatches).toEqual([]);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"dispatches global and flow POST routes with parsed request objects",
|
||||
Effect.fnUntraced(function* () {
|
||||
const { calls, handler } = yield* makeRouteHarness(undefined, { answer: 42 });
|
||||
const headers = { authorization: "Bearer secret" };
|
||||
|
||||
const globalResponse = yield* Effect.promise(() =>
|
||||
handler(makeRequest("/api/v1/config", { operation: "get", key: "demo" }, headers))
|
||||
);
|
||||
const flowResponse = yield* Effect.promise(() =>
|
||||
handler(makeRequest("/api/v1/flow/demo/service/text-completion", { prompt: "hi" }, headers))
|
||||
);
|
||||
|
||||
expect(globalResponse.status).toBe(200);
|
||||
expect(flowResponse.status).toBe(200);
|
||||
expect(calls.dispatches).toEqual([
|
||||
{
|
||||
scope: "global",
|
||||
kind: "config",
|
||||
request: { operation: "get", key: "demo" },
|
||||
},
|
||||
{
|
||||
scope: "flow",
|
||||
flow: "demo",
|
||||
kind: "text-completion",
|
||||
request: { prompt: "hi" },
|
||||
},
|
||||
]);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"publishes flow load requests with generated metadata",
|
||||
Effect.fnUntraced(function* () {
|
||||
const { calls, handler } = yield* makeRouteHarness();
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(
|
||||
makeRequest(
|
||||
"/api/v1/flow/demo/load",
|
||||
{ documentId: "doc-1", user: "alice", collection: "docs" },
|
||||
{ authorization: "Bearer secret" },
|
||||
),
|
||||
)
|
||||
);
|
||||
const body = yield* Effect.promise(() => response.json()) as { status?: string; documentId?: string; flow?: string };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body).toEqual({ status: "processing", documentId: "doc-1", flow: "demo" });
|
||||
expect(calls.publishes).toHaveLength(1);
|
||||
expect(calls.publishes[0]?.topic).toBe("tg.flow.document");
|
||||
expect(calls.publishes[0]?.message).toMatchObject({
|
||||
documentId: "doc-1",
|
||||
metadata: {
|
||||
root: "doc-1",
|
||||
user: "alice",
|
||||
collection: "docs",
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"checks the RPC route token before delegating to the native websocket effect",
|
||||
Effect.fnUntraced(function* () {
|
||||
const { calls, handler } = yield* makeRouteHarness();
|
||||
|
||||
const rejected = yield* Effect.promise(() =>
|
||||
handler(new Request("http://localhost/api/v1/rpc?token=wrong"))
|
||||
);
|
||||
const accepted = yield* Effect.promise(() =>
|
||||
handler(new Request("http://localhost/api/v1/rpc?token=secret"))
|
||||
);
|
||||
|
||||
expect(rejected.status).toBe(401);
|
||||
expect(accepted.status).toBe(200);
|
||||
expect(yield* Effect.promise(() => accepted.text())).toBe("rpc ok");
|
||||
expect(calls.rpcCalls).toBe(1);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import { Effect, Queue } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as RpcMessage from "effect/unstable/rpc/RpcMessage";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as Socket from "effect/unstable/socket/Socket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeSocketRpcProtocol } from "../gateway/rpc-protocol.js";
|
||||
|
||||
interface ReceivedMessage {
|
||||
readonly clientId: number;
|
||||
readonly message: RpcMessage.FromClientEncoded;
|
||||
}
|
||||
|
||||
interface ProtocolRunResult {
|
||||
readonly messages: ReadonlyArray<ReceivedMessage>;
|
||||
readonly writes: ReadonlyArray<string | Uint8Array | CloseEvent>;
|
||||
readonly clientIds: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
const jsonFrame = (value: unknown): string => `${JSON.stringify(value)}\n`;
|
||||
|
||||
const optionToArray = <A>(value: O.Option<A>): Array<A> =>
|
||||
O.match(value, {
|
||||
onNone: () => [],
|
||||
onSome: (item) => [item],
|
||||
});
|
||||
|
||||
const runProtocolFrames = (
|
||||
frames: ReadonlyArray<string | Uint8Array>,
|
||||
headers?: ReadonlyArray<[string, string]>,
|
||||
sendResponse?: RpcMessage.FromServerEncoded,
|
||||
): Promise<ProtocolRunResult> =>
|
||||
Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const received = yield* Queue.unbounded<ReceivedMessage>();
|
||||
const writes: Array<string | Uint8Array | CloseEvent> = [];
|
||||
const { onSocket, protocol } = yield* makeSocketRpcProtocol;
|
||||
|
||||
yield* protocol.run((clientId, message) =>
|
||||
Queue.offer(received, { clientId, message }).pipe(Effect.asVoid)
|
||||
).pipe(Effect.forkScoped);
|
||||
yield* Effect.yieldNow;
|
||||
|
||||
const socket = Socket.make({
|
||||
writer: Effect.succeed((chunk) =>
|
||||
Effect.sync(() => {
|
||||
writes.push(chunk);
|
||||
})
|
||||
),
|
||||
runRaw: (handler) =>
|
||||
Effect.forEach(frames, (frame) =>
|
||||
Effect.suspend(() => {
|
||||
const result = handler(frame);
|
||||
return result === undefined ? Effect.void : result;
|
||||
}), { discard: true }),
|
||||
});
|
||||
|
||||
yield* onSocket(socket, headers);
|
||||
yield* Effect.yieldNow;
|
||||
const clientIds = yield* protocol.clientIds;
|
||||
if (sendResponse !== undefined) {
|
||||
yield* protocol.send(0, sendResponse);
|
||||
}
|
||||
|
||||
const first = yield* Queue.poll(received);
|
||||
const second = yield* Queue.poll(received);
|
||||
const third = yield* Queue.poll(received);
|
||||
|
||||
return {
|
||||
messages: [
|
||||
...optionToArray(first),
|
||||
...optionToArray(second),
|
||||
...optionToArray(third),
|
||||
],
|
||||
writes,
|
||||
clientIds: Array.from(clientIds),
|
||||
};
|
||||
}).pipe(
|
||||
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.ndjson),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
describe("gateway RPC socket protocol", () => {
|
||||
it("validates client request frames and prepends websocket headers", async () => {
|
||||
const result = await runProtocolFrames([
|
||||
jsonFrame({
|
||||
_tag: "Request",
|
||||
id: "1",
|
||||
tag: "Dispatch",
|
||||
payload: {
|
||||
scope: "global",
|
||||
service: "config",
|
||||
request: {},
|
||||
},
|
||||
headers: [["rpc", "client"]],
|
||||
}),
|
||||
], [["socket", "header"]]);
|
||||
|
||||
expect(result.writes).toEqual([]);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
|
||||
const received = result.messages[0];
|
||||
expect(received).toBeDefined();
|
||||
if (received === undefined) return;
|
||||
|
||||
expect(received.clientId).toBe(0);
|
||||
expect(received.message._tag).toBe("Request");
|
||||
if (received.message._tag !== "Request") return;
|
||||
|
||||
expect(received.message.id).toBe("1");
|
||||
expect(received.message.tag).toBe("Dispatch");
|
||||
expect(received.message.headers).toEqual([
|
||||
["socket", "header"],
|
||||
["rpc", "client"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("validates client control frames without mutating them", async () => {
|
||||
const result = await runProtocolFrames([
|
||||
jsonFrame({
|
||||
_tag: "Ping",
|
||||
}),
|
||||
jsonFrame({
|
||||
_tag: "Ack",
|
||||
requestId: "1",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.writes).toEqual([]);
|
||||
expect(result.messages.map(({ message }) => message._tag)).toEqual(["Ping", "Ack"]);
|
||||
expect(result.messages[1]?.message).toEqual({
|
||||
_tag: "Ack",
|
||||
requestId: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects server response envelopes received from the socket", async () => {
|
||||
const result = await runProtocolFrames([
|
||||
jsonFrame({
|
||||
_tag: "Exit",
|
||||
requestId: "1",
|
||||
exit: {
|
||||
_tag: "Success",
|
||||
value: { ok: true },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.writes).toHaveLength(1);
|
||||
expect(String(result.writes[0])).toContain("\"_tag\":\"Defect\"");
|
||||
});
|
||||
|
||||
it("sends server responses through the registered client", async () => {
|
||||
const result = await runProtocolFrames(
|
||||
[],
|
||||
undefined,
|
||||
RpcMessage.ResponseDefectEncoded("server-boom"),
|
||||
);
|
||||
|
||||
expect(result.clientIds).toEqual([0]);
|
||||
expect(result.writes).toHaveLength(1);
|
||||
expect(String(result.writes[0])).toContain("server-boom");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
import { Effect, Queue, Scope } from "effect";
|
||||
import * as MutableHashMap from "effect/MutableHashMap";
|
||||
import * as MutableHashSet from "effect/MutableHashSet";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
import * as RpcMessage from "effect/unstable/rpc/RpcMessage";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as RpcServer from "effect/unstable/rpc/RpcServer";
|
||||
import * as Socket from "effect/unstable/socket/Socket";
|
||||
|
||||
export class RpcProtocolDecodeError extends S.TaggedErrorClass<RpcProtocolDecodeError>()(
|
||||
"RpcProtocolDecodeError",
|
||||
{
|
||||
message: S.String,
|
||||
cause: S.Unknown,
|
||||
},
|
||||
) {}
|
||||
|
||||
const HeaderSchema = S.mutable(S.Tuple([S.String, S.String]));
|
||||
const FromClientEncodedSchema = S.Union([
|
||||
S.TaggedStruct("Request", {
|
||||
id: S.String,
|
||||
tag: S.String,
|
||||
payload: S.Unknown,
|
||||
headers: S.Array(HeaderSchema),
|
||||
traceId: S.optionalKey(S.String),
|
||||
spanId: S.optionalKey(S.String),
|
||||
sampled: S.optionalKey(S.Boolean),
|
||||
}),
|
||||
S.TaggedStruct("Ack", {
|
||||
requestId: S.String,
|
||||
}),
|
||||
S.TaggedStruct("Interrupt", {
|
||||
requestId: S.String,
|
||||
}),
|
||||
S.TaggedStruct("Ping", {}),
|
||||
S.TaggedStruct("Eof", {}),
|
||||
]).pipe(S.toTaggedUnion("_tag"));
|
||||
const FromClientEncodedMessagesSchema = S.Array(FromClientEncodedSchema);
|
||||
const decodeFromClientMessages = S.decodeUnknownEffect(FromClientEncodedMessagesSchema);
|
||||
|
||||
export const makeSocketRpcProtocol = Effect.gen(function* () {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization;
|
||||
const disconnects = yield* Queue.make<number>();
|
||||
|
||||
let nextClientId = 0;
|
||||
const clients = MutableHashMap.empty<number, {
|
||||
readonly write: (response: RpcMessage.FromServerEncoded) => Effect.Effect<void>;
|
||||
}>();
|
||||
const clientIds = MutableHashSet.empty<number>();
|
||||
|
||||
let writeRequest!: (
|
||||
clientId: number,
|
||||
message: RpcMessage.FromClientEncoded,
|
||||
) => Effect.Effect<void>;
|
||||
|
||||
const onSocket = function* (
|
||||
socket: Socket.Socket,
|
||||
headers?: ReadonlyArray<[string, string]>,
|
||||
) {
|
||||
const scope = yield* Effect.scope;
|
||||
const parser = serialization.makeUnsafe();
|
||||
const clientId = nextClientId++;
|
||||
|
||||
yield* Scope.addFinalizerExit(scope, () => {
|
||||
MutableHashMap.remove(clients, clientId);
|
||||
MutableHashSet.remove(clientIds, clientId);
|
||||
return Queue.offer(disconnects, clientId);
|
||||
});
|
||||
|
||||
const writeRaw = yield* socket.writer;
|
||||
const writeDefect = (cause: unknown) =>
|
||||
Effect.sync(() => parser.encode(RpcMessage.ResponseDefectEncoded(cause))).pipe(
|
||||
Effect.flatMap((encoded) => encoded === undefined ? Effect.void : writeRaw(encoded)),
|
||||
);
|
||||
const write = (response: RpcMessage.FromServerEncoded) =>
|
||||
Effect.sync(() => parser.encode(response)).pipe(
|
||||
Effect.flatMap((encoded) =>
|
||||
encoded === undefined ? Effect.void : Effect.orDie(writeRaw(encoded)),
|
||||
),
|
||||
Effect.catchDefect((cause: unknown) =>
|
||||
writeDefect(cause).pipe(Effect.orDie),
|
||||
),
|
||||
);
|
||||
|
||||
MutableHashMap.set(clients, clientId, { write });
|
||||
MutableHashSet.add(clientIds, clientId);
|
||||
|
||||
yield* socket.runRaw((data) =>
|
||||
Effect.try({
|
||||
try: () => parser.decode(data),
|
||||
catch: (cause) =>
|
||||
RpcProtocolDecodeError.make({
|
||||
message: "Failed to decode RPC socket frame",
|
||||
cause,
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.flatMap((raw) =>
|
||||
decodeFromClientMessages(raw).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
RpcProtocolDecodeError.make({
|
||||
message: "RPC socket frame did not contain valid client messages",
|
||||
cause,
|
||||
})
|
||||
),
|
||||
)
|
||||
),
|
||||
Effect.flatMap((decoded) =>
|
||||
Effect.forEach(decoded, (message) => {
|
||||
if (message._tag === "Request" && headers !== undefined) {
|
||||
return writeRequest(clientId, {
|
||||
...message,
|
||||
headers: headers.concat(message.headers),
|
||||
});
|
||||
}
|
||||
return writeRequest(clientId, message);
|
||||
}, { discard: true }),
|
||||
),
|
||||
Effect.catch((cause) => writeDefect(cause)),
|
||||
Effect.catchDefect((cause: unknown) => writeDefect(cause)),
|
||||
)
|
||||
).pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.orDie,
|
||||
);
|
||||
};
|
||||
|
||||
const protocol = yield* RpcServer.Protocol.make((writeRequest_) => {
|
||||
writeRequest = writeRequest_;
|
||||
return Effect.succeed({
|
||||
disconnects,
|
||||
send: (clientId, response) =>
|
||||
O.match(MutableHashMap.get(clients, clientId), {
|
||||
onNone: () => Effect.void,
|
||||
onSome: (client) => Effect.orDie(client.write(response)),
|
||||
}),
|
||||
end: () => Effect.void,
|
||||
clientIds: Effect.sync(() => new Set(clientIds)),
|
||||
initialMessage: Effect.succeedNone,
|
||||
supportsAck: true,
|
||||
supportsTransferables: false,
|
||||
supportsSpanPropagation: true,
|
||||
});
|
||||
});
|
||||
|
||||
return { onSocket, protocol } as const;
|
||||
});
|
||||
|
|
@ -1,23 +1,23 @@
|
|||
import { Cause, Effect, Layer, Queue, Scope } from "effect";
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
||||
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||
import * as RpcServer from "effect/unstable/rpc/RpcServer";
|
||||
import type * as Socket from "effect/unstable/socket/Socket";
|
||||
import { errorMessage } from "@trustgraph/base";
|
||||
import type { DispatcherManager, DispatcherStreamError } from "./dispatch/manager.js";
|
||||
import { DispatchError, DispatchPayload, DispatchStreamChunk, TrustGraphRpcs } from "./rpc-contract.js";
|
||||
import { makeSocketRpcProtocol } from "./rpc-protocol.js";
|
||||
|
||||
export interface GatewayRpcServer {
|
||||
readonly onSocket: (
|
||||
socket: Socket.Socket,
|
||||
headers?: ReadonlyArray<[string, string]>,
|
||||
) => Effect.Effect<void, never, Scope.Scope>;
|
||||
readonly httpEffect: Effect.Effect<
|
||||
HttpServerResponse.HttpServerResponse,
|
||||
never,
|
||||
Scope.Scope | HttpServerRequest.HttpServerRequest
|
||||
>;
|
||||
}
|
||||
|
||||
export const makeGatewayRpcServer = Effect.fn("makeGatewayRpcServer")(function* (
|
||||
dispatcher: DispatcherManager,
|
||||
) {
|
||||
const { onSocket, protocol } = yield* makeSocketRpcProtocol;
|
||||
const { httpEffect, protocol } = yield* RpcServer.makeProtocolWithHttpEffectWebsocket;
|
||||
|
||||
const serverLayer = RpcServer.layer(TrustGraphRpcs, {
|
||||
disableFatalDefects: true,
|
||||
|
|
@ -30,9 +30,7 @@ export const makeGatewayRpcServer = Effect.fn("makeGatewayRpcServer")(function*
|
|||
yield* Layer.launch(serverLayer).pipe(Effect.forkScoped);
|
||||
|
||||
return {
|
||||
onSocket: Effect.fn("GatewayRpc.onSocket")(function* (socket, headers) {
|
||||
yield* onSocket(socket, headers);
|
||||
}),
|
||||
httpEffect,
|
||||
} satisfies GatewayRpcServer;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -54,10 +54,25 @@ const dispatchResult = (result: unknown) => {
|
|||
|
||||
const readJsonRecord = Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest;
|
||||
const body = yield* request.json;
|
||||
return isRecord(body) ? body : {};
|
||||
const body = yield* request.json.pipe(
|
||||
Effect.mapError(() => "request body must be valid JSON"),
|
||||
);
|
||||
if (!isRecord(body)) {
|
||||
return yield* Effect.fail("request body must be a JSON object");
|
||||
}
|
||||
return body;
|
||||
});
|
||||
|
||||
const withJsonRecord = <R>(
|
||||
use: (body: Record<string, unknown>) => Effect.Effect<HttpServerResponse.HttpServerResponse, never, R>,
|
||||
): Effect.Effect<HttpServerResponse.HttpServerResponse, never, HttpServerRequest.HttpServerRequest | R> =>
|
||||
readJsonRecord.pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (message) => Effect.succeed(badRequest(message)),
|
||||
onSuccess: use,
|
||||
}),
|
||||
);
|
||||
|
||||
const bearerAuthResponse = (config: GatewayConfig) =>
|
||||
Effect.gen(function* () {
|
||||
if (config.secret === undefined || config.secret.length === 0) return null;
|
||||
|
|
@ -98,26 +113,25 @@ const workbenchDispatch = (
|
|||
) =>
|
||||
withBearerAuth(
|
||||
config,
|
||||
Effect.gen(function* () {
|
||||
const body = yield* readJsonRecord.pipe(
|
||||
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
|
||||
);
|
||||
const service = typeof body.service === "string" ? body.service : undefined;
|
||||
const payload = isRecord(body.request) ? body.request : undefined;
|
||||
if (service === undefined || service.length === 0 || payload === undefined) {
|
||||
return badRequest("service and request are required");
|
||||
}
|
||||
withJsonRecord((body) =>
|
||||
Effect.gen(function* () {
|
||||
const service = typeof body.service === "string" ? body.service : undefined;
|
||||
const payload = isRecord(body.request) ? body.request : undefined;
|
||||
if (service === undefined || service.length === 0 || payload === undefined) {
|
||||
return badRequest("service and request are required");
|
||||
}
|
||||
|
||||
const dispatch = body.scope === "flow"
|
||||
? dispatcher.dispatchFlowService(
|
||||
typeof body.flow === "string" ? body.flow : "default",
|
||||
service,
|
||||
payload,
|
||||
)
|
||||
: dispatcher.dispatchGlobalService(service, payload);
|
||||
const dispatch = body.scope === "flow"
|
||||
? dispatcher.dispatchFlowService(
|
||||
typeof body.flow === "string" ? body.flow : "default",
|
||||
service,
|
||||
payload,
|
||||
)
|
||||
: dispatcher.dispatchGlobalService(service, payload);
|
||||
|
||||
return yield* withDispatchError(dispatch, "workbench-dispatch");
|
||||
}),
|
||||
return yield* withDispatchError(dispatch, "workbench-dispatch");
|
||||
})
|
||||
),
|
||||
);
|
||||
|
||||
const globalDispatch = (
|
||||
|
|
@ -128,12 +142,11 @@ const globalDispatch = (
|
|||
config,
|
||||
Effect.gen(function* () {
|
||||
const params = yield* HttpRouter.params;
|
||||
const body = yield* readJsonRecord.pipe(
|
||||
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
|
||||
);
|
||||
return yield* withDispatchError(
|
||||
dispatcher.dispatchGlobalService(params.kind ?? "", body),
|
||||
"global-dispatch",
|
||||
return yield* withJsonRecord((body) =>
|
||||
withDispatchError(
|
||||
dispatcher.dispatchGlobalService(params.kind ?? "", body),
|
||||
"global-dispatch",
|
||||
)
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
@ -146,12 +159,11 @@ const flowDispatch = (
|
|||
config,
|
||||
Effect.gen(function* () {
|
||||
const params = yield* HttpRouter.params;
|
||||
const body = yield* readJsonRecord.pipe(
|
||||
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
|
||||
);
|
||||
return yield* withDispatchError(
|
||||
dispatcher.dispatchFlowService(params.flow ?? "default", params.kind ?? "", body),
|
||||
"flow-dispatch",
|
||||
return yield* withJsonRecord((body) =>
|
||||
withDispatchError(
|
||||
dispatcher.dispatchFlowService(params.flow ?? "default", params.kind ?? "", body),
|
||||
"flow-dispatch",
|
||||
)
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
@ -164,33 +176,34 @@ const flowLoad = (
|
|||
config,
|
||||
Effect.gen(function* () {
|
||||
const params = yield* HttpRouter.params;
|
||||
const body = yield* readJsonRecord.pipe(
|
||||
Effect.catch(() => Effect.succeed<Record<string, unknown>>({})),
|
||||
return yield* withJsonRecord((body) =>
|
||||
Effect.gen(function* () {
|
||||
const documentId = typeof body.documentId === "string" ? body.documentId : undefined;
|
||||
if (documentId === undefined || documentId.length === 0) {
|
||||
return badRequest("documentId is required");
|
||||
}
|
||||
|
||||
const user = typeof body.user === "string" ? body.user : "default";
|
||||
const collection = typeof body.collection === "string" ? body.collection : "default";
|
||||
const timestamp = yield* Clock.currentTimeMillis;
|
||||
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
|
||||
const metadata = {
|
||||
id: `load-${timestamp}-${suffix.toString(36).padStart(6, "0")}`,
|
||||
root: documentId,
|
||||
user,
|
||||
collection,
|
||||
};
|
||||
|
||||
yield* dispatcher.publishToTopic("tg.flow.document", { metadata, documentId }).pipe(
|
||||
Effect.mapError((cause) => messagingLifecycleError("gateway", "publish-load", cause)),
|
||||
);
|
||||
|
||||
return json({ status: "processing", documentId, flow: params.flow ?? "default" });
|
||||
}).pipe(
|
||||
Effect.catch((error) => Effect.succeed(dispatchError(error))),
|
||||
)
|
||||
);
|
||||
const documentId = typeof body.documentId === "string" ? body.documentId : undefined;
|
||||
if (documentId === undefined || documentId.length === 0) {
|
||||
return badRequest("documentId is required");
|
||||
}
|
||||
|
||||
const user = typeof body.user === "string" ? body.user : "default";
|
||||
const collection = typeof body.collection === "string" ? body.collection : "default";
|
||||
const timestamp = yield* Clock.currentTimeMillis;
|
||||
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
|
||||
const metadata = {
|
||||
id: `load-${timestamp}-${suffix.toString(36).padStart(6, "0")}`,
|
||||
root: documentId,
|
||||
user,
|
||||
collection,
|
||||
};
|
||||
|
||||
yield* dispatcher.publishToTopic("tg.flow.document", { metadata, documentId }).pipe(
|
||||
Effect.mapError((cause) => messagingLifecycleError("gateway", "publish-load", cause)),
|
||||
);
|
||||
|
||||
return json({ status: "processing", documentId, flow: params.flow ?? "default" });
|
||||
}).pipe(
|
||||
Effect.catch((error) => Effect.succeed(dispatchError(error))),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const rpcRoute = (
|
||||
|
|
@ -206,14 +219,10 @@ const rpcRoute = (
|
|||
return json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const socket = yield* request.upgrade;
|
||||
yield* rpcServer.onSocket(socket, headersFrom(request.headers)).pipe(
|
||||
return yield* rpcServer.httpEffect.pipe(
|
||||
Scope.provide(rpcScope),
|
||||
);
|
||||
return HttpServerResponse.empty();
|
||||
}).pipe(
|
||||
Effect.catch((error) => Effect.succeed(dispatchError(error))),
|
||||
);
|
||||
});
|
||||
|
||||
const metricsRoute =
|
||||
formatPrometheusMetrics.pipe(
|
||||
|
|
@ -224,7 +233,7 @@ const metricsRoute =
|
|||
),
|
||||
);
|
||||
|
||||
const gatewayRoutes = (
|
||||
export const makeGatewayRoutes = (
|
||||
config: GatewayConfig,
|
||||
dispatcher: DispatcherManager,
|
||||
rpcServer: GatewayRpcServer,
|
||||
|
|
@ -265,7 +274,7 @@ export function createGateway(config: GatewayConfig) {
|
|||
);
|
||||
|
||||
const serverLayer = HttpRouter.serve(
|
||||
gatewayRoutes(config, dispatcher, rpcServer, rpcScope),
|
||||
makeGatewayRoutes(config, dispatcher, rpcServer, rpcScope),
|
||||
).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(createServer, {
|
||||
port: config.port,
|
||||
|
|
@ -279,15 +288,6 @@ export function createGateway(config: GatewayConfig) {
|
|||
);
|
||||
}
|
||||
|
||||
function headersFrom(headers: Record<string, string | string[] | number | undefined>): ReadonlyArray<[string, string]> {
|
||||
return Object.entries(headers).flatMap(([key, value]) => {
|
||||
if (typeof value === "string") return [[key, value] satisfies [string, string]];
|
||||
if (typeof value === "number") return [[key, String(value)] satisfies [string, string]];
|
||||
if (Array.isArray(value)) return value.map((item) => [key, item] satisfies [string, string]);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export function runMain(): void {
|
||||
NodeRuntime.runMain(program);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue