267 lines
8.5 KiB
TypeScript
267 lines
8.5 KiB
TypeScript
import {
|
|
createServer as createHttpServer,
|
|
type Server as HttpServer,
|
|
type IncomingMessage,
|
|
type ServerResponse,
|
|
} from "node:http";
|
|
import { createServer as createHttpsServer } from "node:https";
|
|
import type { TlsOptions } from "node:tls";
|
|
import type { WebSocketServer } from "ws";
|
|
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
|
|
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
|
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import { handleSlackHttpRequest } from "../slack/http/index.js";
|
|
import { handleControlUiHttpRequest } from "./control-ui.js";
|
|
import {
|
|
extractHookToken,
|
|
getHookChannelError,
|
|
type HookMessageChannel,
|
|
type HooksConfigResolved,
|
|
normalizeAgentPayload,
|
|
normalizeHookHeaders,
|
|
normalizeWakePayload,
|
|
readJsonBody,
|
|
resolveHookChannel,
|
|
resolveHookDeliver,
|
|
} from "./hooks.js";
|
|
import { applyHookMappings } from "./hooks-mapping.js";
|
|
import { handleOpenAiHttpRequest } from "./openai-http.js";
|
|
|
|
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
|
|
|
type HookDispatchers = {
|
|
dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void;
|
|
dispatchAgentHook: (value: {
|
|
message: string;
|
|
name: string;
|
|
wakeMode: "now" | "next-heartbeat";
|
|
sessionKey: string;
|
|
deliver: boolean;
|
|
channel: HookMessageChannel;
|
|
to?: string;
|
|
model?: string;
|
|
thinking?: string;
|
|
timeoutSeconds?: number;
|
|
}) => string;
|
|
};
|
|
|
|
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
|
res.statusCode = status;
|
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
|
|
export function createHooksRequestHandler(
|
|
opts: {
|
|
getHooksConfig: () => HooksConfigResolved | null;
|
|
bindHost: string;
|
|
port: number;
|
|
logHooks: SubsystemLogger;
|
|
} & HookDispatchers,
|
|
): HooksRequestHandler {
|
|
const { getHooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts;
|
|
return async (req, res) => {
|
|
const hooksConfig = getHooksConfig();
|
|
if (!hooksConfig) return false;
|
|
const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`);
|
|
const basePath = hooksConfig.basePath;
|
|
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
|
|
return false;
|
|
}
|
|
|
|
const token = extractHookToken(req, url);
|
|
if (!token || token !== hooksConfig.token) {
|
|
res.statusCode = 401;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end("Unauthorized");
|
|
return true;
|
|
}
|
|
|
|
if (req.method !== "POST") {
|
|
res.statusCode = 405;
|
|
res.setHeader("Allow", "POST");
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end("Method Not Allowed");
|
|
return true;
|
|
}
|
|
|
|
const subPath = url.pathname.slice(basePath.length).replace(/^\/+/, "");
|
|
if (!subPath) {
|
|
res.statusCode = 404;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end("Not Found");
|
|
return true;
|
|
}
|
|
|
|
const body = await readJsonBody(req, hooksConfig.maxBodyBytes);
|
|
if (!body.ok) {
|
|
const status = body.error === "payload too large" ? 413 : 400;
|
|
sendJson(res, status, { ok: false, error: body.error });
|
|
return true;
|
|
}
|
|
|
|
const payload = typeof body.value === "object" && body.value !== null ? body.value : {};
|
|
const headers = normalizeHookHeaders(req);
|
|
|
|
if (subPath === "wake") {
|
|
const normalized = normalizeWakePayload(payload as Record<string, unknown>);
|
|
if (!normalized.ok) {
|
|
sendJson(res, 400, { ok: false, error: normalized.error });
|
|
return true;
|
|
}
|
|
dispatchWakeHook(normalized.value);
|
|
sendJson(res, 200, { ok: true, mode: normalized.value.mode });
|
|
return true;
|
|
}
|
|
|
|
if (subPath === "agent") {
|
|
const normalized = normalizeAgentPayload(payload as Record<string, unknown>);
|
|
if (!normalized.ok) {
|
|
sendJson(res, 400, { ok: false, error: normalized.error });
|
|
return true;
|
|
}
|
|
const runId = dispatchAgentHook(normalized.value);
|
|
sendJson(res, 202, { ok: true, runId });
|
|
return true;
|
|
}
|
|
|
|
if (hooksConfig.mappings.length > 0) {
|
|
try {
|
|
const mapped = await applyHookMappings(hooksConfig.mappings, {
|
|
payload: payload as Record<string, unknown>,
|
|
headers,
|
|
url,
|
|
path: subPath,
|
|
});
|
|
if (mapped) {
|
|
if (!mapped.ok) {
|
|
sendJson(res, 400, { ok: false, error: mapped.error });
|
|
return true;
|
|
}
|
|
if (mapped.action === null) {
|
|
res.statusCode = 204;
|
|
res.end();
|
|
return true;
|
|
}
|
|
if (mapped.action.kind === "wake") {
|
|
dispatchWakeHook({
|
|
text: mapped.action.text,
|
|
mode: mapped.action.mode,
|
|
});
|
|
sendJson(res, 200, { ok: true, mode: mapped.action.mode });
|
|
return true;
|
|
}
|
|
const channel = resolveHookChannel(mapped.action.channel);
|
|
if (!channel) {
|
|
sendJson(res, 400, { ok: false, error: getHookChannelError() });
|
|
return true;
|
|
}
|
|
const runId = dispatchAgentHook({
|
|
message: mapped.action.message,
|
|
name: mapped.action.name ?? "Hook",
|
|
wakeMode: mapped.action.wakeMode,
|
|
sessionKey: mapped.action.sessionKey ?? "",
|
|
deliver: resolveHookDeliver(mapped.action.deliver),
|
|
channel,
|
|
to: mapped.action.to,
|
|
model: mapped.action.model,
|
|
thinking: mapped.action.thinking,
|
|
timeoutSeconds: mapped.action.timeoutSeconds,
|
|
});
|
|
sendJson(res, 202, { ok: true, runId });
|
|
return true;
|
|
}
|
|
} catch (err) {
|
|
logHooks.warn(`hook mapping failed: ${String(err)}`);
|
|
sendJson(res, 500, { ok: false, error: "hook mapping failed" });
|
|
return true;
|
|
}
|
|
}
|
|
|
|
res.statusCode = 404;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end("Not Found");
|
|
return true;
|
|
};
|
|
}
|
|
|
|
export function createGatewayHttpServer(opts: {
|
|
canvasHost: CanvasHostHandler | null;
|
|
controlUiEnabled: boolean;
|
|
controlUiBasePath: string;
|
|
openAiChatCompletionsEnabled: boolean;
|
|
handleHooksRequest: HooksRequestHandler;
|
|
handlePluginRequest?: HooksRequestHandler;
|
|
resolvedAuth: import("./auth.js").ResolvedGatewayAuth;
|
|
tlsOptions?: TlsOptions;
|
|
}): HttpServer {
|
|
const {
|
|
canvasHost,
|
|
controlUiEnabled,
|
|
controlUiBasePath,
|
|
openAiChatCompletionsEnabled,
|
|
handleHooksRequest,
|
|
handlePluginRequest,
|
|
resolvedAuth,
|
|
} = opts;
|
|
const httpServer: HttpServer = opts.tlsOptions
|
|
? createHttpsServer(opts.tlsOptions, (req, res) => {
|
|
void handleRequest(req, res);
|
|
})
|
|
: createHttpServer((req, res) => {
|
|
void handleRequest(req, res);
|
|
});
|
|
|
|
async function handleRequest(req: IncomingMessage, res: ServerResponse) {
|
|
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
|
|
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
|
|
|
|
try {
|
|
if (await handleHooksRequest(req, res)) return;
|
|
if (await handleSlackHttpRequest(req, res)) return;
|
|
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
|
if (openAiChatCompletionsEnabled) {
|
|
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
|
|
}
|
|
if (canvasHost) {
|
|
if (await handleA2uiHttpRequest(req, res)) return;
|
|
if (await canvasHost.handleHttpRequest(req, res)) return;
|
|
}
|
|
if (controlUiEnabled) {
|
|
if (
|
|
handleControlUiHttpRequest(req, res, {
|
|
basePath: controlUiBasePath,
|
|
})
|
|
)
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 404;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end("Not Found");
|
|
} catch (err) {
|
|
res.statusCode = 500;
|
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
res.end(String(err));
|
|
}
|
|
}
|
|
|
|
return httpServer;
|
|
}
|
|
|
|
export function attachGatewayUpgradeHandler(opts: {
|
|
httpServer: HttpServer;
|
|
wss: WebSocketServer;
|
|
canvasHost: CanvasHostHandler | null;
|
|
}) {
|
|
const { httpServer, wss, canvasHost } = opts;
|
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
if (canvasHost?.handleUpgrade(req, socket, head)) return;
|
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
wss.emit("connection", ws, req);
|
|
});
|
|
});
|
|
}
|