feat: add provider websocket http data plane

This commit is contained in:
Codex
2026-05-16 16:03:53 +00:00
parent 659d1c6148
commit 28cc2af121
19 changed files with 545 additions and 22 deletions
@@ -1,6 +1,7 @@
import type { JsonValue } from "../../shared/src/index";
import type {
EgressTcpConnection,
HttpTunnelWaiter,
LoggerFn,
MicroserviceAvailabilityEntry,
MicroserviceProxyCacheEntry,
@@ -22,6 +23,7 @@ export const ctx = {
activeProviders: new Map<string, ProviderSocket>(),
activeSshClients: new Map<string, ProviderSocket>(),
activeEgressTcpConnections: new Map<string, EgressTcpConnection>(),
httpTunnelWaiters: new Map<string, HttpTunnelWaiter>(),
taskTerminalWaiters: new Map<string, Set<TaskTerminalWaiter>>(),
microserviceProxyCache: new Map<string, MicroserviceProxyCacheEntry>(),
microserviceProxyRefreshes: new Map<string, Promise<void>>(),
+6
View File
@@ -172,6 +172,12 @@ const providerServer = Bun.serve<WsData>({
logger("warn", "provider_socket_close", { providerId: providerId ?? null });
if (providerId !== undefined) {
closeEgressTcpConnectionsForProvider(providerId);
for (const [requestId, waiter] of ctx.httpTunnelWaiters) {
if (requestId.startsWith(`${providerId}:`)) {
ctx.httpTunnelWaiters.delete(requestId);
waiter(null);
}
}
if (ctx.activeProviders.get(providerId) !== ws) {
logger("info", "provider_socket_close_ignored_replaced", { providerId });
return;
@@ -202,11 +202,15 @@ function responseFromMicroserviceResult(task: RawTaskRow | null): Response {
if (task === null) return jsonResponse({ ok: false, error: "microservice proxy task missing" }, 502);
if (task.status !== "succeeded") return jsonResponse({ ok: false, error: "microservice proxy task failed", task }, 502);
const result = dockerStatusRecord(task.result);
return responseFromProviderMicroserviceResult(result, "provider-task");
}
function responseFromProviderMicroserviceResult(result: Record<string, unknown>, proxyMode: string): Response {
const status = Number(result.status);
const contentType = typeof result.contentType === "string" ? result.contentType : "application/json; charset=utf-8";
const bodyText = typeof result.bodyText === "string" ? result.bodyText : "";
if (!Number.isInteger(status) || status < 100 || status > 599) {
return jsonResponse({ ok: false, error: "microservice proxy returned invalid upstream status", task }, 502);
return jsonResponse({ ok: false, error: "microservice proxy returned invalid upstream status", result }, 502);
}
if (result.truncated === true && contentTypeIsJson(contentType)) {
try {
@@ -215,8 +219,6 @@ function responseFromMicroserviceResult(task: RawTaskRow | null): Response {
return jsonResponse({
ok: false,
error: "microservice proxy response was truncated before a JSON boundary",
providerId: task.provider_id,
command: task.command,
upstreamStatus: status,
upstreamBodyBytes: result.upstreamBodyBytes ?? null,
returnedBodyBytes: result.returnedBodyBytes ?? bodyText.length,
@@ -229,6 +231,8 @@ function responseFromMicroserviceResult(task: RawTaskRow | null): Response {
status,
headers: {
"content-type": contentType,
"x-unidesk-proxy-mode": proxyMode,
"x-unidesk-upstream-proxy-mode": typeof result.proxyMode === "string" ? result.proxyMode : "",
"x-unidesk-response-truncated": result.truncated === true ? "true" : "false",
},
});
@@ -607,6 +611,84 @@ async function k3sctlAdapterMicroserviceResponse(
return fetchMicroserviceUpstreamResponse(adapter, method, adapterTargetPath, proxyOptions, requestHeaders, bodyText, abortSignal);
}
function providerHttpTunnelRequestId(providerId: string): string {
return `${providerId}:http_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
async function waitForProviderHttpTunnelResponse(
providerId: string,
requestId: string,
timeoutMs: number,
abortSignal?: AbortSignal,
): Promise<{ providerId: string; requestId: string; ok: boolean; result: JsonValue } | null> {
return await new Promise((resolve) => {
let settled = false;
let abortHandler: (() => void) | null = null;
const timer = setTimeout(() => settle(null), Math.max(1, timeoutMs));
const settle = (message: { providerId: string; requestId: string; ok: boolean; result: JsonValue } | null): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (abortHandler !== null) abortSignal?.removeEventListener("abort", abortHandler);
ctx.httpTunnelWaiters.delete(requestId);
resolve(message);
};
abortHandler = () => settle(null);
if (abortSignal !== undefined) {
if (abortSignal.aborted) {
settle(null);
return;
}
abortSignal.addEventListener("abort", abortHandler, { once: true });
}
ctx.httpTunnelWaiters.set(requestId, (message) => {
if (message !== null && message.providerId !== providerId) {
logger("warn", "http_tunnel_provider_mismatch", { requestId, expectedProviderId: providerId, actualProviderId: message.providerId });
settle(null);
return;
}
settle(message);
});
});
}
async function providerHttpTunnelMicroserviceResponse(
service: MicroserviceConfig,
method: string,
targetPath: string,
proxyOptions: { query: string; jsonArrayLimits: Record<string, JsonValue> },
requestHeaders: Record<string, JsonValue>,
bodyText: string,
abortSignal?: AbortSignal,
): Promise<Response> {
const socket = ctx.activeProviders.get(service.providerId);
if (socket === undefined) return jsonResponse({ ok: false, error: `provider is offline: ${service.providerId}` }, 503);
const requestId = providerHttpTunnelRequestId(service.providerId);
const timeoutMs = service.backend.timeoutMs + 3000;
const waiter = waitForProviderHttpTunnelResponse(service.providerId, requestId, timeoutMs, abortSignal);
socket.send(JSON.stringify({
type: "http_tunnel_request",
requestId,
payload: {
source: "microservice-frontend-proxy",
serviceId: service.id,
method,
targetBaseUrl: service.backend.nodeBaseUrl,
path: targetPath,
query: proxyOptions.query,
requestHeaders,
bodyText,
jsonArrayLimits: proxyOptions.jsonArrayLimits,
timeoutMs: service.backend.timeoutMs,
cacheTtlMs: providerMicroserviceCacheTtlMs(service.id, targetPath),
},
}));
const message = await waiter;
if (message === null) return jsonResponse({ ok: false, error: "provider HTTP tunnel timed out or disconnected", providerId: service.providerId, requestId }, 504);
if (!message.ok) return jsonResponse({ ok: false, error: "provider HTTP tunnel failed", providerId: service.providerId, requestId, result: message.result }, 502);
return responseFromProviderMicroserviceResult(dockerStatusRecord(message.result), "provider-ws-http-tunnel");
}
async function fetchMicroserviceUpstreamResponse(
service: MicroserviceConfig,
method: string,
@@ -625,6 +707,9 @@ async function fetchMicroserviceUpstreamResponse(
if (!(await providerSupports(service.providerId, "microservice.http"))) {
return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${service.providerId}` }, 409);
}
if (await providerSupports(service.providerId, "microservice.http.tunnel")) {
return providerHttpTunnelMicroserviceResponse(service, method, targetPath, proxyOptions, requestHeaders, bodyText, abortSignal);
}
const { taskId, providerOnline } = await createAndSendTask(service.providerId, "microservice.http", {
source: "microservice-frontend-proxy",
serviceId: service.id,
@@ -81,6 +81,22 @@ export async function handleProviderMessage(ws: ProviderSocket, raw: string | Bu
return;
}
if (message.type === "http_tunnel_response") {
const waiter = ctx.httpTunnelWaiters.get(message.requestId);
if (waiter === undefined) {
logger("warn", "http_tunnel_response_without_waiter", { providerId: message.providerId, requestId: message.requestId });
return;
}
ctx.httpTunnelWaiters.delete(message.requestId);
waiter({
providerId: message.providerId,
requestId: message.requestId,
ok: message.ok,
result: message.result,
});
return;
}
if (message.type === "egress_tcp_open") {
handleEgressTcpOpen(ws, message);
return;
+7
View File
@@ -188,4 +188,11 @@ export interface MicroserviceAvailabilityEntry {
probe: Record<string, JsonValue>;
}
export type HttpTunnelWaiter = (message: {
providerId: string;
requestId: string;
ok: boolean;
result: JsonValue;
} | null) => void;
export type LoggerFn = (level: "debug" | "info" | "warn" | "error", message: string, data?: JsonValue) => void;