fix(ts): close effect native review blockers

This commit is contained in:
elpresidank 2026-06-06 11:01:17 -05:00
parent b6759e75df
commit a26463afc1
13 changed files with 438 additions and 528 deletions

View 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);
}),
);
});

View file

@ -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");
});
});

View file

@ -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;
});

View file

@ -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;
});

View file

@ -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);
}